From fde32d3137f268d8a9e6115080022035350ad088 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 10 Oct 2026 14:51:11 +0530 Subject: [PATCH 1/8] chore: ignore only the root ctags file A bare `tags` pattern also ignored buzz/api/tags/. --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 086ba65b..bd9f92e8 100644 --- a/.gitignore +++ b/.gitignore @@ -20,7 +20,7 @@ wheels/ *.egg-info/ .installed.cfg *.egg -tags +/tags MANIFEST # Environments From 0917653e39dc484c67879e273b0b948bb0447f42 Mon Sep 17 00:00:00 2001 From: Harsh Tandiya Date: Sat, 10 Oct 2026 14:51:26 +0530 Subject: [PATCH 2/8] feat(sponsorships): team tags on sponsors, with a tag filter and desk sidebar Tags are Buzz Tag records scoped to a team and a kind of record, so two teams, or sponsors and events of one team, can each have their own "VIP". A unique index on (team, document_type, label) enforces it. Buzz Tag Link joins a tag to a record and checks the two belong to the same team and type. - Sponsor drawer: tag picker with a Create Tag button, applied immediately. - Sponsor list: moves to its own endpoint with search, sort and filters, including a Tags filter; cards show their tags. - Desk: a record's tag labels are mirrored into `_user_tags`, so the sidebar, list pills, tag counts and Tags filter show them. Desk's add_tag, add_tags, remove_tag and get_tags are overridden for taggable doctypes only and write Buzz Tags; every other doctype keeps Frappe's tags. - Patch converts existing Frappe tags on taggable records into team Buzz Tags. Taggable doctypes are listed in the `taggable_doctypes` hook: Event Sponsor, Buzz Event, Talk Proposal and Buzz Team Membership. Co-Authored-By: Claude Opus 5.5 --- buzz/api/filters/conditions.py | 16 ++ buzz/api/sponsorships/__init__.py | 10 + buzz/api/sponsorships/manage.py | 32 +--- buzz/api/sponsorships/schemas.py | 13 ++ buzz/api/sponsorships/sponsors.py | 78 ++++++++ buzz/api/sponsorships/test_manage.py | 1 + buzz/api/sponsorships/test_sponsors.py | 56 ++++++ buzz/api/tags/__init__.py | 15 ++ buzz/api/tags/desk.py | 48 +++++ buzz/api/tags/exceptions.py | 13 ++ buzz/api/tags/schemas.py | 6 + buzz/api/tags/services.py | 92 +++++++++ buzz/api/tags/test_tags.py | 177 ++++++++++++++++++ buzz/events/doctype/buzz_tag/__init__.py | 0 buzz/events/doctype/buzz_tag/buzz_tag.json | 94 ++++++++++ buzz/events/doctype/buzz_tag/buzz_tag.py | 61 ++++++ buzz/events/doctype/buzz_tag_link/__init__.py | 0 .../doctype/buzz_tag_link/buzz_tag_link.json | 69 +++++++ .../doctype/buzz_tag_link/buzz_tag_link.py | 36 ++++ buzz/hooks.py | 15 +- buzz/patches.txt | 1 + .../patches/migrate_desk_tags_to_buzz_tags.py | 15 ++ .../test_migrate_desk_tags_to_buzz_tags.py | 26 +++ buzz/tests/factories/__init__.py | 2 + buzz/tests/factories/buzz_tag_factory.py | 23 +++ dashboard/components.d.ts | 2 + dashboard/src/components/common/TagPicker.vue | 124 ++++++++++++ .../components/common/filters/ListFilters.vue | 14 +- .../src/components/common/filters/fields.ts | 1 + .../dashboard/sponsorships/SponsorCard.vue | 5 + .../dashboard/sponsorships/SponsorDrawer.vue | 17 +- .../sponsorships/SponsorsSection.vue | 84 +++++++++ dashboard/src/composables/useListQuery.ts | 9 +- dashboard/src/data/sponsorships.ts | 26 ++- .../pages/manage/events/EventSponsorships.vue | 43 ++--- dashboard/src/types.ts | 15 ++ e2e/tests/sponsor-tags.spec.ts | 108 +++++++++++ 37 files changed, 1281 insertions(+), 66 deletions(-) create mode 100644 buzz/api/sponsorships/sponsors.py create mode 100644 buzz/api/sponsorships/test_sponsors.py create mode 100644 buzz/api/tags/__init__.py create mode 100644 buzz/api/tags/desk.py create mode 100644 buzz/api/tags/exceptions.py create mode 100644 buzz/api/tags/schemas.py create mode 100644 buzz/api/tags/services.py create mode 100644 buzz/api/tags/test_tags.py create mode 100644 buzz/events/doctype/buzz_tag/__init__.py create mode 100644 buzz/events/doctype/buzz_tag/buzz_tag.json create mode 100644 buzz/events/doctype/buzz_tag/buzz_tag.py create mode 100644 buzz/events/doctype/buzz_tag_link/__init__.py create mode 100644 buzz/events/doctype/buzz_tag_link/buzz_tag_link.json create mode 100644 buzz/events/doctype/buzz_tag_link/buzz_tag_link.py create mode 100644 buzz/patches/migrate_desk_tags_to_buzz_tags.py create mode 100644 buzz/patches/test_migrate_desk_tags_to_buzz_tags.py create mode 100644 buzz/tests/factories/buzz_tag_factory.py create mode 100644 dashboard/src/components/common/TagPicker.vue create mode 100644 dashboard/src/components/dashboard/sponsorships/SponsorsSection.vue create mode 100644 e2e/tests/sponsor-tags.spec.ts diff --git a/buzz/api/filters/conditions.py b/buzz/api/filters/conditions.py index 18b563e8..941e8044 100644 --- a/buzz/api/filters/conditions.py +++ b/buzz/api/filters/conditions.py @@ -16,6 +16,9 @@ "Attach": "file", "Attach Image": "file", } +# A record's Buzz Tags, matched through its Buzz Tag Link rows. +TAGS_KEY = "tags" + NEGATED_OPERATORS = {"not in": "in", "not like": "like"} # Answers saved through `str()` carry Python's spelling: "True" for a tick, ['a', 'b'] for a list. CHECKED_VALUES = ["1", "True", "true"] @@ -64,6 +67,10 @@ def filter_field(key, label, fieldtype, options=(), section="standard") -> Filte ) +def tags_field(tags) -> FilterField: + return filter_field(TAGS_KEY, _("Tags"), "Link", [(tag.name, tag.label) for tag in tags]) + + def question_fields(questions) -> list[FilterField]: """Buzz Form Field rows as filters; answers are matched in Additional Field by fieldname.""" return [ @@ -125,12 +132,21 @@ def is_valid(self, triple) -> bool: def translate(self, key: str, operator: str, value) -> list | None: if is_blank(operator, value): return None + if key == TAGS_KEY: + return ["name", operator, self.tagged_names(value)] field = self.fields[key] if field.section == "question": doctype, link_field = self.answer_parents.get(key, (self.doctype, "name")) return AnswerCondition(doctype, link_field, field, operator, value).name_filter() return [key, operator, f"%{value}%" if "like" in operator else value] + def tagged_names(self, tags: list[str]) -> list[str]: + return frappe.get_all( + "Buzz Tag Link", + filters={"document_type": self.doctype, "tag": ["in", tags]}, + pluck="document_name", + ) + class AnswerCondition: """One condition on a custom question, resolved to the parents whose answer matches. diff --git a/buzz/api/sponsorships/__init__.py b/buzz/api/sponsorships/__init__.py index 6abbd537..e4d40663 100644 --- a/buzz/api/sponsorships/__init__.py +++ b/buzz/api/sponsorships/__init__.py @@ -13,10 +13,12 @@ EnquiryDetail, EventEnquiriesResponse, EventSponsorshipsResponse, + EventSponsorsResponse, SponsorshipDetailsResponse, SponsorshipListItem, ) from buzz.api.sponsorships.services import SponsorshipService, list_user_enquiries +from buzz.api.sponsorships.sponsors import event_sponsors # nosemgrep: guest-whitelisted-method @@ -63,6 +65,14 @@ def get_event_sponsorships(event: str) -> EventSponsorshipsResponse: return event_sponsorships(event) +@frappe.whitelist(methods=["GET"]) +def get_event_sponsors( + event: str, search: str | None = None, filters: str | None = None, order: str = "desc" +) -> EventSponsorsResponse: + """`filters` is a JSON list of `[field, operator, value]`, the string the dashboard keeps in its URL.""" + return event_sponsors(event, search, filters, order) + + @frappe.whitelist(methods=["GET"]) def get_event_sponsorship_enquiries( event: str, diff --git a/buzz/api/sponsorships/manage.py b/buzz/api/sponsorships/manage.py index c66515ce..bf83cc7a 100644 --- a/buzz/api/sponsorships/manage.py +++ b/buzz/api/sponsorships/manage.py @@ -5,23 +5,13 @@ from buzz.api.events.services import ensure_event_team_access from buzz.api.sponsorships.schemas import ( EnquiryFormState, - EventSponsorItem, EventSponsorshipsResponse, TierItem, ) +from buzz.api.sponsorships.sponsors import sponsor_items, sponsor_rows, sponsor_tags from buzz.permissions import has_team_access TIER_FIELDS = ["name", "title", "enabled", "perks", "slots", {"prices": ["currency", "price"]}] -SPONSOR_FIELDS = [ - "name", - "company_name", - "company_logo", - "website", - "country", - "contact_email", - "enquiry", - "tier", -] def event_sponsorships(event: str) -> EventSponsorshipsResponse: @@ -36,14 +26,8 @@ def event_sponsorships(event: str) -> EventSponsorshipsResponse: order_by="creation asc", ignore_permissions=True, ) - sponsors = frappe.get_all( - "Event Sponsor", - filters={"event": event}, - fields=SPONSOR_FIELDS, - order_by="creation asc", - ignore_permissions=True, - ) - tier_titles = {tier.name: tier.title for tier in tiers} + sponsors = sponsor_rows({"event": event}) + tier_titles = {str(tier.name): tier.title for tier in tiers} sponsor_counts = Counter(sponsor.tier for sponsor in sponsors) return EventSponsorshipsResponse( @@ -51,13 +35,9 @@ def event_sponsorships(event: str) -> EventSponsorshipsResponse: can_write=has_team_access(doc.team, "write", frappe.session.user), form=form_state(doc), tiers=[TierItem(**tier, sponsor_count=sponsor_counts[tier.name]) for tier in tiers], - sponsors=[ - EventSponsorItem( - **sponsor, - tier_title=tier_titles.get(sponsor.tier, sponsor.tier) if sponsor.tier else "", - ) - for sponsor in sponsors - ], + sponsors=sponsor_items(sponsors, tier_titles), + team=doc.team, + tags=sponsor_tags(event), ) diff --git a/buzz/api/sponsorships/schemas.py b/buzz/api/sponsorships/schemas.py index 51d43433..57b9c27d 100644 --- a/buzz/api/sponsorships/schemas.py +++ b/buzz/api/sponsorships/schemas.py @@ -2,6 +2,7 @@ from buzz.api.filters.schemas import FilterField from buzz.api.schemas import APIResponse +from buzz.api.tags.schemas import TagItem class EnquiryFormState(APIResponse): @@ -35,6 +36,7 @@ class EventSponsorItem(APIResponse): enquiry: str | None tier: str | None tier_title: str + tags: list[TagItem] class EventEnquiryItem(APIResponse): @@ -56,6 +58,17 @@ class EventSponsorshipsResponse(APIResponse): form: EnquiryFormState | None tiers: list[TierItem] sponsors: list[EventSponsorItem] + team: str + # The team's sponsor tags, which the sponsor drawer offers. + tags: list[TagItem] + + +class EventSponsorsResponse(APIResponse): + """An event's sponsors after search and filters, with what the list can be filtered by.""" + + total: int + sponsors: list[EventSponsorItem] + filter_fields: list[FilterField] class EventEnquiriesResponse(APIResponse): diff --git a/buzz/api/sponsorships/sponsors.py b/buzz/api/sponsorships/sponsors.py new file mode 100644 index 00000000..adac4f50 --- /dev/null +++ b/buzz/api/sponsorships/sponsors.py @@ -0,0 +1,78 @@ +import frappe +from frappe import _ + +from buzz.api.events.services import ensure_event_team_access +from buzz.api.filters.conditions import ListConditions, filter_field, tags_field +from buzz.api.filters.schemas import FilterField +from buzz.api.sponsorships.enquiries import search_filters +from buzz.api.sponsorships.schemas import EventSponsorItem, EventSponsorsResponse +from buzz.api.tags.schemas import TagItem +from buzz.api.tags.services import tags_by_document, team_tags + +SPONSOR_FIELDS = [ + "name", + "company_name", + "company_logo", + "website", + "country", + "contact_email", + "enquiry", + "tier", +] + + +def event_sponsors( + event: str, search: str | None = None, filters: str | None = None, order: str = "desc" +) -> EventSponsorsResponse: + """An event's sponsors narrowed by search and filters; few enough per event to skip paging.""" + ensure_event_team_access(event) + filter_fields = sponsor_filter_fields(event) + conditions = ListConditions("Event Sponsor", filter_fields).frappe_filters(filters) + # Interpolated into ORDER BY, so it can only ever be one of two literals. + direction = "asc" if str(order).lower() == "asc" else "desc" + rows = sponsor_rows([["event", "=", event], *conditions], search_filters(search), direction) + return EventSponsorsResponse( + total=frappe.db.count("Event Sponsor", {"event": event}), + sponsors=sponsor_items(rows, tier_titles(event)), + filter_fields=filter_fields, + ) + + +def sponsor_rows(filters: list, or_filters: list | None = None, direction: str = "asc") -> list: + return frappe.get_all( + "Event Sponsor", + filters=filters, + or_filters=or_filters, + fields=SPONSOR_FIELDS, + order_by=f"creation {direction}, name {direction}", + ignore_permissions=True, + ) + + +def sponsor_items(rows: list, tier_titles: dict) -> list[EventSponsorItem]: + tags = tags_by_document("Event Sponsor", [row.name for row in rows]) + return [ + EventSponsorItem( + **row, + tags=tags.get(row.name, []), + tier_title=tier_titles.get(str(row.tier), row.tier) if row.tier else "", + ) + for row in rows + ] + + +def tier_titles(event: str) -> dict[str, str]: + tiers = frappe.get_all("Sponsorship Tier", {"event": event}, ["name", "title"], order_by="creation") + return {str(tier.name): tier.title for tier in tiers} + + +def sponsor_filter_fields(event: str) -> list[FilterField]: + return [ + filter_field("tier", _("Tier"), "Link", list(tier_titles(event).items())), + tags_field(sponsor_tags(event)), + ] + + +def sponsor_tags(event: str) -> list[TagItem]: + """Sponsor tags are the team's, shared by all its events.""" + return team_tags(frappe.db.get_value("Buzz Event", event, "team"), "Event Sponsor") diff --git a/buzz/api/sponsorships/test_manage.py b/buzz/api/sponsorships/test_manage.py index 5f7debdf..4d43c467 100644 --- a/buzz/api/sponsorships/test_manage.py +++ b/buzz/api/sponsorships/test_manage.py @@ -22,6 +22,7 @@ "enquiry", "tier", "tier_title", + "tags", } ENQUIRY_FIELDS = { "name", diff --git a/buzz/api/sponsorships/test_sponsors.py b/buzz/api/sponsorships/test_sponsors.py new file mode 100644 index 00000000..3fbb85cb --- /dev/null +++ b/buzz/api/sponsorships/test_sponsors.py @@ -0,0 +1,56 @@ +import json + +import frappe + +from buzz.api.events.exceptions import CannotManageEvent +from buzz.api.sponsorships import get_event_sponsors, get_event_sponsorships +from buzz.api.tags import set_tags +from buzz.tests.base_test_cases import SponsorshipTestCase +from buzz.tests.factories import BuzzEventFactory, BuzzTagFactory, EventSponsorFactory + + +class TestSponsorList(SponsorshipTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.team = frappe.db.get_value("Buzz Event", cls.event, "team") + + def setUp(self): + # Rollback is per class, so each test lists the sponsors of its own event. + self.event = str(BuzzEventFactory.create(team=self.team).name) + super().setUp() + self.acme = self.make_sponsor().name + self.globex = EventSponsorFactory.create(event=self.event, company_name="Globex").name + self.vip = BuzzTagFactory.create(team=self.team) + set_tags("Event Sponsor", self.acme, [self.vip.name]) + + def sponsors(self, *triples, search=None) -> set[str]: + response = get_event_sponsors(self.event, search=search, filters=json.dumps(triples)) + return {sponsor.name for sponsor in response.sponsors} + + def test_sponsor_lists_its_tags(self): + response = get_event_sponsorships(self.event) + + sponsor = next(row for row in response.sponsors if row.name == self.acme) + self.assertEqual([tag.name for tag in sponsor.tags], [self.vip.name]) + self.assertIn(self.vip.name, [tag.name for tag in response.tags]) + + def test_filter_by_tag(self): + self.assertEqual(self.sponsors(["tags", "in", [self.vip.name]]), {self.acme}) + self.assertEqual(self.sponsors(["tags", "not in", [self.vip.name]]), {self.globex}) + + def test_filter_by_tier_and_search(self): + self.assertEqual(self.sponsors(["tier", "in", [str(self.tier.name)]]), {self.acme}) + self.assertEqual(self.sponsors(search="glob"), {self.globex}) + + def test_tag_filter_offers_only_sponsor_tags_of_the_team(self): + event_tag = BuzzTagFactory.create(team=self.team, document_type="Buzz Event").name + + fields = {field.key: field for field in get_event_sponsors(self.event).filter_fields} + offered = [option.value for option in fields["tags"].options] + self.assertIn(self.vip.name, offered) + self.assertNotIn(event_tag, offered) + + def test_non_member_is_refused(self): + with self.set_user(self.make_stranger()), self.assertRaises(CannotManageEvent): + get_event_sponsors(self.event) diff --git a/buzz/api/tags/__init__.py b/buzz/api/tags/__init__.py new file mode 100644 index 00000000..440a6da8 --- /dev/null +++ b/buzz/api/tags/__init__.py @@ -0,0 +1,15 @@ +import frappe + +from buzz.api.tags.schemas import TagItem +from buzz.api.tags.services import DocumentTags, create_team_tag + + +@frappe.whitelist(methods=["POST"]) +def create_tag(team: str, document_type: str, label: str) -> TagItem: + return create_team_tag(team, document_type, label) + + +@frappe.whitelist(methods=["POST"]) +def set_tags(document_type: str, document_name: str, tags: list[str]) -> list[TagItem]: + """Replaces every tag on the record with `tags`, a list of Buzz Tag names.""" + return DocumentTags(document_type, document_name).replace(tags) diff --git a/buzz/api/tags/desk.py b/buzz/api/tags/desk.py new file mode 100644 index 00000000..e888edfc --- /dev/null +++ b/buzz/api/tags/desk.py @@ -0,0 +1,48 @@ +import frappe +from frappe import _ +from frappe.desk.doctype.tag import tag as frappe_tags + +from buzz.api.tags.services import DocumentTags, create_team_tag, is_taggable +from buzz.events.doctype.buzz_tag.buzz_tag import document_team + +# Desk's tag editor, routed to Buzz Tags on taggable records and to Frappe's tags everywhere else. + + +@frappe.whitelist() +def add_tag(tag: str, dt: str, dn: str, color: str | None = None): + if not is_taggable(dt): + return frappe_tags.add_tag(tag, dt, dn, color) + tags = DocumentTags(dt, dn) + added = create_team_tag(document_team(dt, dn), dt, tag) + tags.replace([*tags.names(), added.name]) + return added.label + + +@frappe.whitelist() +def add_tags(tags: str | list[str], dt: str, docs: str | list[str], color: str | None = None): + if not is_taggable(dt): + return frappe_tags.add_tags(tags, dt, docs, color) + if not frappe.get_cached_value("User", frappe.session.user, "bulk_actions"): + frappe.throw(_("You are not allowed to perform bulk actions"), frappe.PermissionError) + for doc in frappe.parse_json(docs): + for tag in frappe.parse_json(tags): + add_tag(tag, dt, doc) + + +@frappe.whitelist() +def remove_tag(tag: str, dt: str, dn: str): + if not is_taggable(dt): + return frappe_tags.remove_tag(tag, dt, dn) + tags = DocumentTags(dt, dn) + removed = frappe.db.get_value( + "Buzz Tag", {"team": document_team(dt, dn), "document_type": dt, "label": tag} + ) + tags.replace([name for name in tags.names() if name != removed]) + + +@frappe.whitelist() +def get_tags(doctype: str, txt: str): + if not is_taggable(doctype): + return frappe_tags.get_tags(doctype, txt) + filters = {"document_type": doctype, "label": ["like", f"%{txt}%"]} + return sorted(set(frappe.get_list("Buzz Tag", filters=filters, pluck="label"))) diff --git a/buzz/api/tags/exceptions.py b/buzz/api/tags/exceptions.py new file mode 100644 index 00000000..ee32206e --- /dev/null +++ b/buzz/api/tags/exceptions.py @@ -0,0 +1,13 @@ +from frappe import _lt + +from buzz.api.exceptions import BuzzAPIError + + +class NotTaggable(BuzzAPIError): + title = _lt("Cannot Tag") + message = _lt("These records cannot be tagged.") + + +class EmptyTagLabel(BuzzAPIError): + title = _lt("Name Required") + message = _lt("Give the tag a name.") diff --git a/buzz/api/tags/schemas.py b/buzz/api/tags/schemas.py new file mode 100644 index 00000000..367af264 --- /dev/null +++ b/buzz/api/tags/schemas.py @@ -0,0 +1,6 @@ +from buzz.api.schemas import APIResponse + + +class TagItem(APIResponse): + name: str + label: str diff --git a/buzz/api/tags/services.py b/buzz/api/tags/services.py new file mode 100644 index 00000000..4bb3f0ab --- /dev/null +++ b/buzz/api/tags/services.py @@ -0,0 +1,92 @@ +import frappe +from frappe.query_builder import DocType + +from buzz.api.tags.exceptions import EmptyTagLabel, NotTaggable +from buzz.api.tags.schemas import TagItem + + +def is_taggable(document_type: str) -> bool: + return document_type in frappe.get_hooks("taggable_doctypes") + + +def ensure_taggable(document_type: str): + if not is_taggable(document_type): + NotTaggable.throw() + + +def create_team_tag(team: str, document_type: str, label: str) -> TagItem: + """A team's tag for one kind of record. Asking for a label the team already has returns that tag.""" + ensure_taggable(document_type) + label = " ".join(label.split()) + if not label: + EmptyTagLabel.throw() + values = {"team": team, "document_type": document_type, "label": label} + tag = frappe.new_doc("Buzz Tag", **values) + tag.check_permission("create") + existing = frappe.db.get_value("Buzz Tag", values, ["name", "label"], as_dict=True) + if existing: + return TagItem(**existing) + tag.insert() + return TagItem(name=tag.name, label=tag.label) + + +def team_tags(team: str, document_type: str) -> list[TagItem]: + rows = frappe.get_all( + "Buzz Tag", + filters={"team": team, "document_type": document_type}, + fields=["name", "label"], + order_by="label asc", + ignore_permissions=True, + ) + return [TagItem(**row) for row in rows] + + +def tags_by_document(document_type: str, names: list) -> dict[str, list[TagItem]]: + """Tags on each of `names`, keyed by the name as a string: Buzz Event names are integers.""" + if not names: + return {} + link, tag = DocType("Buzz Tag Link"), DocType("Buzz Tag") + rows = ( + frappe.qb.from_(link) + .join(tag) + .on(tag.name == link.tag) + .select(link.document_name, tag.name, tag.label) + .where((link.document_type == document_type) & link.document_name.isin([str(n) for n in names])) + .orderby(tag.label) + ).run(as_dict=True) + tags = {} + for row in rows: + tags.setdefault(row.document_name, []).append(TagItem(name=row.name, label=row.label)) + return tags + + +def refresh_user_tags(document_type: str, names: list) -> dict[str, list[TagItem]]: + """Copies tag labels into `_user_tags`, the column desk shows as a record's Tags.""" + tags = tags_by_document(document_type, names) + for name in names: + labels = ",".join(tag.label for tag in tags.get(str(name), [])) + frappe.db.set_value(document_type, name, "_user_tags", labels, update_modified=False) + return tags + + +class DocumentTags: + """The tags on one record. Changing them takes write access to the record.""" + + def __init__(self, document_type: str, document_name: str): + ensure_taggable(document_type) + frappe.has_permission(document_type, "write", doc=document_name, throw=True) + self.filters = {"document_type": document_type, "document_name": document_name} + + def names(self) -> list[str]: + return frappe.get_all("Buzz Tag Link", filters=self.filters, pluck="tag") + + def replace(self, tags: list[str]) -> list[TagItem]: + current = set(self.names()) + if removed := current - set(tags): + frappe.db.delete("Buzz Tag Link", {**self.filters, "tag": ("in", list(removed))}) + for tag in set(tags) - current: + frappe.get_doc({"doctype": "Buzz Tag Link", "tag": tag, **self.filters}).insert( + ignore_permissions=True + ) + document_type, name = self.filters["document_type"], self.filters["document_name"] + return refresh_user_tags(document_type, [name]).get(str(name), []) diff --git a/buzz/api/tags/test_tags.py b/buzz/api/tags/test_tags.py new file mode 100644 index 00000000..c4a64381 --- /dev/null +++ b/buzz/api/tags/test_tags.py @@ -0,0 +1,177 @@ +import frappe + +from buzz.api.tags import create_tag, desk, set_tags +from buzz.api.tags.exceptions import EmptyTagLabel, NotTaggable +from buzz.tests.base_test_cases import TeamPermissionTestCase +from buzz.tests.factories import ( + BuzzTagFactory, + BuzzTeamMembershipFactory, + EventSponsorFactory, + UserFactory, +) + + +class TestCreateTag(TeamPermissionTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.viewer = UserFactory.create_once("tags-viewer@example.com").name + BuzzTeamMembershipFactory.create(team=cls.team_a, user=cls.viewer, team_role="Viewer") + + def create_as(self, user: str, team: str, label: str, document_type: str = "Event Sponsor"): + with self.set_user(user): + return create_tag(team, document_type, label) + + def test_same_label_in_two_teams_stays_apart(self): + tag_a = self.create_as(self.alice, self.team_a, "VIP") + tag_b = self.create_as(self.bob, self.team_b, "VIP") + + self.assertNotEqual(tag_a.name, tag_b.name) + + def test_same_label_on_two_record_types_stays_apart(self): + sponsor_tag = self.create_as(self.alice, self.team_a, "Returning") + event_tag = self.create_as(self.alice, self.team_a, "Returning", "Buzz Event") + + self.assertNotEqual(sponsor_tag.name, event_tag.name) + + def test_existing_label_returns_that_tag(self): + first = self.create_as(self.alice, self.team_a, "Follow up") + again = self.create_as(self.alice, self.team_a, " follow UP ") + + self.assertEqual(again.name, first.name) + + def test_database_refuses_a_second_tag_with_the_same_label(self): + BuzzTagFactory.create(team=self.team_a, label="Speaker") + + with self.assertRaises(frappe.UniqueValidationError): + BuzzTagFactory.create(team=self.team_a, label="speaker") + + def test_viewer_and_outsider_cannot_create(self): + for user in (self.viewer, self.outsider): + with self.assertRaises(frappe.PermissionError): + self.create_as(user, self.team_a, "Nope") + + def test_blank_label_and_untaggable_type_are_refused(self): + with self.assertRaises(EmptyTagLabel): + self.create_as(self.alice, self.team_a, " ") + with self.assertRaises(NotTaggable): + self.create_as(self.alice, self.team_a, "Paid", "Event Booking") + + def test_every_member_reads_their_team_tags_only(self): + own = BuzzTagFactory.create(team=self.team_a).name + other = BuzzTagFactory.create(team=self.team_b).name + + visible = self.list_as(self.viewer, "Buzz Tag") + + self.assertIn(own, visible) + self.assertNotIn(other, visible) + + +class TestSetTags(TeamPermissionTestCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.viewer = UserFactory.create_once("tags-viewer@example.com").name + BuzzTeamMembershipFactory.create(team=cls.team_a, user=cls.viewer, team_role="Viewer") + + def setUp(self): + super().setUp() + self.sponsor = EventSponsorFactory.create(event=self.event_a).name + self.vip = BuzzTagFactory.create(team=self.team_a).name + self.gold = BuzzTagFactory.create(team=self.team_a).name + + def tag_sponsor(self, *tags: str, user: str | None = None) -> set[str]: + with self.set_user(user or self.alice): + return {tag.name for tag in set_tags("Event Sponsor", self.sponsor, list(tags))} + + def test_replaces_the_record_tags(self): + self.assertEqual(self.tag_sponsor(self.vip, self.gold), {self.vip, self.gold}) + self.assertEqual(self.tag_sponsor(self.gold), {self.gold}) + self.assertEqual(self.tag_sponsor(), set()) + + def test_tag_of_another_team_or_type_is_refused(self): + other_team = BuzzTagFactory.create(team=self.team_b).name + event_tag = BuzzTagFactory.create(team=self.team_a, document_type="Buzz Event").name + + for tag in (other_team, event_tag): + with self.assertRaises(frappe.ValidationError): + self.tag_sponsor(tag) + + def test_viewer_cannot_tag(self): + with self.assertRaises(frappe.PermissionError): + self.tag_sponsor(self.vip, user=self.viewer) + + def test_event_names_are_integers(self): + tag = BuzzTagFactory.create(team=self.team_a, document_type="Buzz Event").name + + with self.set_user(self.alice): + tags = set_tags("Buzz Event", str(self.event_a), [tag]) + + self.assertEqual([item.name for item in tags], [tag]) + + def test_deleting_the_record_or_the_tag_removes_links(self): + self.tag_sponsor(self.vip, self.gold) + + frappe.delete_doc("Buzz Tag", self.gold) + self.assertEqual(frappe.db.count("Buzz Tag Link", {"document_name": self.sponsor}), 1) + + frappe.delete_doc("Event Sponsor", self.sponsor) + self.assertFalse(frappe.db.exists("Buzz Tag Link", {"document_name": self.sponsor})) + + +class TestDeskTags(TeamPermissionTestCase): + def setUp(self): + super().setUp() + self.sponsor = EventSponsorFactory.create(event=self.event_a).name + + def desk_tags(self) -> str: + return frappe.db.get_value("Event Sponsor", self.sponsor, "_user_tags") + + def test_buzz_tags_show_in_desk_and_follow_renames_and_deletes(self): + vip = BuzzTagFactory.create(team=self.team_a, label="VIP") + gold = BuzzTagFactory.create(team=self.team_a, label="Gold") + with self.set_user(self.alice): + set_tags("Event Sponsor", self.sponsor, [vip.name, gold.name]) + self.assertEqual(self.desk_tags(), "Gold,VIP") + + vip.label = "Headline" + vip.save() + self.assertEqual(self.desk_tags(), "Gold,Headline") + + gold.delete() + self.assertEqual(self.desk_tags(), "Headline") + + def test_desk_editor_adds_and_removes_team_tags(self): + with self.set_user(self.alice): + desk.add_tag("Returning", "Event Sponsor", self.sponsor) + tag = frappe.db.get_value( + "Buzz Tag", {"label": "Returning", "document_type": "Event Sponsor"}, "team" + ) + self.assertEqual(tag, self.team_a) + self.assertEqual(self.desk_tags(), "Returning") + + with self.set_user(self.alice): + desk.remove_tag("Returning", "Event Sponsor", self.sponsor) + self.assertEqual(self.desk_tags(), "") + + def test_desk_suggests_only_the_users_team_tags(self): + BuzzTagFactory.create(team=self.team_a, label="Alpha Only") + BuzzTagFactory.create(team=self.team_b, label="Beta Only") + + with self.set_user(self.alice): + suggested = desk.get_tags("Event Sponsor", "only") + + self.assertEqual(suggested, ["Alpha Only"]) + + def test_label_with_a_comma_is_refused(self): + with self.assertRaises(frappe.ValidationError): + BuzzTagFactory.create(team=self.team_a, label="Gold, Silver") + + def test_other_doctypes_keep_frappe_tags(self): + label = f"Frappe {frappe.generate_hash(length=8)}" + + desk.add_tag(label, "User", self.alice) + + self.assertTrue(frappe.db.exists("Tag", label)) + self.assertIn(label, frappe.db.get_value("User", self.alice, "_user_tags")) + self.assertFalse(frappe.db.exists("Buzz Tag", {"label": label})) diff --git a/buzz/events/doctype/buzz_tag/__init__.py b/buzz/events/doctype/buzz_tag/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/events/doctype/buzz_tag/buzz_tag.json b/buzz/events/doctype/buzz_tag/buzz_tag.json new file mode 100644 index 00000000..ee367b7a --- /dev/null +++ b/buzz/events/doctype/buzz_tag/buzz_tag.json @@ -0,0 +1,94 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-10-09 12:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "label", + "team", + "column_break_type", + "document_type" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label", + "reqd": 1 + }, + { + "fieldname": "team", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Team", + "options": "Buzz Team", + "reqd": 1, + "set_only_once": 1 + }, + { + "fieldname": "column_break_type", + "fieldtype": "Column Break" + }, + { + "description": "The kind of record this tag is applied to", + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Applies To", + "options": "DocType", + "reqd": 1, + "set_only_once": 1 + } + ], + "links": [], + "modified": "2026-10-09 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Buzz Tag", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "Event Manager", + "select": 1, + "share": 1, + "write": 1 + }, + { + "read": 1, + "report": 1, + "role": "Buzz User" + } + ], + "quick_entry": 1, + "row_format": "Dynamic", + "search_fields": "label", + "show_title_field_in_link": 1, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "title_field": "label" +} diff --git a/buzz/events/doctype/buzz_tag/buzz_tag.py b/buzz/events/doctype/buzz_tag/buzz_tag.py new file mode 100644 index 00000000..5eee1e9c --- /dev/null +++ b/buzz/events/doctype/buzz_tag/buzz_tag.py @@ -0,0 +1,61 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +from buzz.api.tags.services import is_taggable, refresh_user_tags + + +class BuzzTag(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + document_type: DF.Link + label: DF.Data + team: DF.Link + # end: auto-generated types + + def validate(self): + self.label = " ".join(self.label.split()) + # Desk keeps a record's tags as one comma-joined string. + if "," in self.label: + frappe.throw(_("A tag name cannot contain a comma.")) + if not is_taggable(self.document_type): + frappe.throw(_("{0} records cannot be tagged.").format(_(self.document_type))) + + def on_update(self): + if self.has_value_changed("label"): + refresh_user_tags(self.document_type, self.tagged_names()) + + def on_trash(self): + names = self.tagged_names() + frappe.db.delete("Buzz Tag Link", {"tag": self.name}) + refresh_user_tags(self.document_type, names) + + def tagged_names(self) -> list[str]: + return frappe.get_all("Buzz Tag Link", {"tag": self.name}, pluck="document_name") + + +def on_doctype_update(): + # A label repeats across teams and record types, never within one. The collation ignores case. + frappe.db.add_unique("Buzz Tag", ["team", "document_type", "label"], constraint_name="unique_tag_label") + + +def document_team(doctype: str, name: str) -> str | None: + """The team a record belongs to, directly or through its event.""" + if frappe.get_meta(doctype).has_field("team"): + return frappe.db.get_value(doctype, name, "team") + event = frappe.db.get_value(doctype, name, "event") + return frappe.db.get_value("Buzz Event", event, "team") if event else None + + +def delete_tag_links(doc, method=None): + """Untag a record as it is deleted, before Frappe checks what still links to it.""" + frappe.db.delete("Buzz Tag Link", {"document_type": doc.doctype, "document_name": doc.name}) diff --git a/buzz/events/doctype/buzz_tag_link/__init__.py b/buzz/events/doctype/buzz_tag_link/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/events/doctype/buzz_tag_link/buzz_tag_link.json b/buzz/events/doctype/buzz_tag_link/buzz_tag_link.json new file mode 100644 index 00000000..018c47eb --- /dev/null +++ b/buzz/events/doctype/buzz_tag_link/buzz_tag_link.json @@ -0,0 +1,69 @@ +{ + "actions": [], + "autoname": "hash", + "creation": "2026-10-09 12:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "tag", + "column_break_document", + "document_type", + "document_name" + ], + "fields": [ + { + "fieldname": "tag", + "fieldtype": "Link", + "in_list_view": 1, + "in_standard_filter": 1, + "label": "Tag", + "options": "Buzz Tag", + "reqd": 1 + }, + { + "fieldname": "column_break_document", + "fieldtype": "Column Break" + }, + { + "fieldname": "document_type", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Document Type", + "options": "DocType", + "reqd": 1 + }, + { + "fieldname": "document_name", + "fieldtype": "Dynamic Link", + "in_list_view": 1, + "label": "Document Name", + "options": "document_type", + "reqd": 1 + } + ], + "links": [], + "modified": "2026-10-09 12:00:00.000000", + "modified_by": "Administrator", + "module": "Events", + "name": "Buzz Tag Link", + "naming_rule": "Random", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/events/doctype/buzz_tag_link/buzz_tag_link.py b/buzz/events/doctype/buzz_tag_link/buzz_tag_link.py new file mode 100644 index 00000000..4159c17e --- /dev/null +++ b/buzz/events/doctype/buzz_tag_link/buzz_tag_link.py @@ -0,0 +1,36 @@ +# Copyright (c) 2026, BWH Studios and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + +from buzz.events.doctype.buzz_tag.buzz_tag import document_team + + +class BuzzTagLink(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + document_name: DF.DynamicLink + document_type: DF.Link + tag: DF.Link + # end: auto-generated types + + def validate(self): + tag = frappe.db.get_value("Buzz Tag", self.tag, ["team", "document_type"], as_dict=True) + record_team = document_team(self.document_type, self.document_name) + if tag.document_type != self.document_type or tag.team != record_team: + frappe.throw(_("This tag belongs to another team or kind of record.")) + + +def on_doctype_update(): + frappe.db.add_unique( + "Buzz Tag Link", ["tag", "document_type", "document_name"], constraint_name="unique_tag_link" + ) + frappe.db.add_index("Buzz Tag Link", ["document_type", "document_name"]) diff --git a/buzz/hooks.py b/buzz/hooks.py index 7b1652ff..5e96b741 100644 --- a/buzz/hooks.py +++ b/buzz/hooks.py @@ -87,6 +87,11 @@ }, } +# Records a Buzz Tag can be applied to; deleting one also removes its tags. +taggable_doctypes = ["Event Sponsor", "Buzz Event", "Talk Proposal", "Buzz Team Membership"] +for _doctype in taggable_doctypes: + doc_events.setdefault(_doctype, {})["on_trash"] = "buzz.events.doctype.buzz_tag.buzz_tag.delete_tag_links" + fixtures = [ {"dt": "Role", "filters": {"name": ["in", ["Buzz User", "Event Manager", "Frontdesk Manager"]]}}, {"dt": "Buzz Theme", "filters": {"is_standard": 1}}, @@ -220,6 +225,7 @@ "Event Host": "buzz.permissions.team_query_conditions", "Event Template": "buzz.permissions.team_query_conditions", "Event Venue": "buzz.permissions.team_query_conditions", + "Buzz Tag": "buzz.permissions.team_query_conditions", "Buzz Team Settings": "buzz.permissions.team_query_conditions", "Additional Event Page": "buzz.permissions.derived_query_conditions", "Buzz Coupon Code": "buzz.permissions.derived_query_conditions", @@ -254,6 +260,7 @@ "Event Host": "buzz.permissions.team_has_permission", "Event Template": "buzz.permissions.team_has_permission", "Event Venue": "buzz.permissions.team_has_permission", + "Buzz Tag": "buzz.permissions.team_has_permission", "Buzz Team Settings": "buzz.permissions.team_admin_has_permission", "Additional Event Page": "buzz.permissions.derived_has_permission", "Buzz Coupon Code": "buzz.permissions.derived_has_permission", @@ -285,9 +292,11 @@ # Overriding Methods # ------------------------------ # -# override_whitelisted_methods = { -# "frappe.desk.doctype.event.event.get_events": "events.event.get_events" -# } +# Desk's tag editor reads and writes Buzz Tags on taggable records. +override_whitelisted_methods = { + f"frappe.desk.doctype.tag.tag.{_method}": f"buzz.api.tags.desk.{_method}" + for _method in ("add_tag", "add_tags", "remove_tag", "get_tags") +} # # each overriding function accepts a `data` argument; # generated from the base implementation of the doctype dashboard, diff --git a/buzz/patches.txt b/buzz/patches.txt index a83de640..5564abf2 100644 --- a/buzz/patches.txt +++ b/buzz/patches.txt @@ -31,3 +31,4 @@ buzz.patches.move_ticket_type_price_to_prices buzz.patches.create_administrator_team buzz.patches.geocode_event_venues buzz.patches.rename_is_a_community_field +buzz.patches.migrate_desk_tags_to_buzz_tags diff --git a/buzz/patches/migrate_desk_tags_to_buzz_tags.py b/buzz/patches/migrate_desk_tags_to_buzz_tags.py new file mode 100644 index 00000000..5c5c7059 --- /dev/null +++ b/buzz/patches/migrate_desk_tags_to_buzz_tags.py @@ -0,0 +1,15 @@ +import frappe + +from buzz.api.tags.services import DocumentTags, create_team_tag +from buzz.events.doctype.buzz_tag.buzz_tag import document_team + + +def execute(): + """Desk now shows a taggable record's Buzz Tags, so the Frappe tags it had become its team's Buzz Tags.""" + for doctype in frappe.get_hooks("taggable_doctypes"): + records = frappe.get_all(doctype, {"_user_tags": ["is", "set"]}, ["name", "_user_tags"]) + for record in records: + team = document_team(doctype, record.name) + labels = [label for label in record._user_tags.split(",") if label.strip()] + tags = DocumentTags(doctype, record.name) + tags.replace([*tags.names(), *(create_team_tag(team, doctype, label).name for label in labels)]) diff --git a/buzz/patches/test_migrate_desk_tags_to_buzz_tags.py b/buzz/patches/test_migrate_desk_tags_to_buzz_tags.py new file mode 100644 index 00000000..73549696 --- /dev/null +++ b/buzz/patches/test_migrate_desk_tags_to_buzz_tags.py @@ -0,0 +1,26 @@ +import frappe +from frappe.tests import IntegrationTestCase + +from buzz.api.tags import set_tags +from buzz.patches.migrate_desk_tags_to_buzz_tags import execute +from buzz.tests.factories import BuzzEventFactory, BuzzTagFactory, EventSponsorFactory + + +class TestMigrateDeskTagsToBuzzTags(IntegrationTestCase): + def test_frappe_tags_become_team_tags_alongside_existing_ones(self): + event = BuzzEventFactory.create() + sponsor = EventSponsorFactory.create(event=event.name).name + kept = BuzzTagFactory.create(team=event.team, label=f"Gold {frappe.generate_hash(length=8)}") + set_tags("Event Sponsor", sponsor, [kept.name]) + desk_label = f"Returning {frappe.generate_hash(length=8)}" + frappe.db.set_value("Event Sponsor", sponsor, "_user_tags", f"{kept.label},{desk_label} ,") + + execute() + + labels = sorted([kept.label, desk_label]) + migrated = frappe.get_all( + "Buzz Tag", {"team": event.team, "label": ["in", labels]}, pluck="label", order_by="label" + ) + self.assertEqual(migrated, labels) + self.assertEqual(frappe.db.count("Buzz Tag Link", {"document_name": sponsor}), 2) + self.assertEqual(frappe.db.get_value("Event Sponsor", sponsor, "_user_tags"), ",".join(labels)) diff --git a/buzz/tests/factories/__init__.py b/buzz/tests/factories/__init__.py index 32bca5db..29f2e467 100644 --- a/buzz/tests/factories/__init__.py +++ b/buzz/tests/factories/__init__.py @@ -3,6 +3,7 @@ from buzz.tests.factories.buzz_coupon_code_factory import BuzzCouponCodeFactory from buzz.tests.factories.buzz_custom_field_factory import BuzzCustomFieldFactory from buzz.tests.factories.buzz_event_factory import BuzzEventFactory +from buzz.tests.factories.buzz_tag_factory import BuzzTagFactory from buzz.tests.factories.buzz_team_factory import BuzzTeamFactory from buzz.tests.factories.buzz_team_membership_factory import BuzzTeamMembershipFactory from buzz.tests.factories.buzz_theme_factory import BuzzThemeFactory @@ -40,6 +41,7 @@ "BuzzCouponCodeFactory", "BuzzCustomFieldFactory", "BuzzEventFactory", + "BuzzTagFactory", "BuzzTeamFactory", "BuzzTeamMembershipFactory", "BuzzThemeFactory", diff --git a/buzz/tests/factories/buzz_tag_factory.py b/buzz/tests/factories/buzz_tag_factory.py new file mode 100644 index 00000000..543714ca --- /dev/null +++ b/buzz/tests/factories/buzz_tag_factory.py @@ -0,0 +1,23 @@ +from typing import Any + +import frappe +from frappe_factory_bot.frappe_factory_bot.base_factory import BaseFactory + +from buzz.events.doctype.buzz_tag.buzz_tag import BuzzTag + + +class BuzzTagFactory(BaseFactory[BuzzTag]): + """A sponsor tag on a new team unless told otherwise.""" + + doctype = "Buzz Tag" + + @property + def default_attributes(self) -> dict[str, Any]: + from buzz.tests.factories import BuzzTeamFactory + + return { + "team": self.overrides.get("team") or BuzzTeamFactory.create_owned_by().name, + "document_type": "Event Sponsor", + # Unique within a team and record type, and teams outlive the per-class rollback. + "label": f"Tag {frappe.generate_hash(length=8)}", + } diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index 76eac997..16fae0a6 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -141,8 +141,10 @@ declare module 'vue' { SponsorDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorDrawer.vue')['default'] SponsorshipDrawer: typeof import('./src/components/dashboard/sponsorships/SponsorshipDrawer.vue')['default'] SponsorshipPaymentDialog: typeof import('./src/components/SponsorshipPaymentDialog.vue')['default'] + SponsorsSection: typeof import('./src/components/dashboard/sponsorships/SponsorsSection.vue')['default'] SubmissionThumbnail: typeof import('./src/components/dashboard/teams/SubmissionThumbnail.vue')['default'] SuccessMessage: typeof import('./src/components/SuccessMessage.vue')['default'] + TagPicker: typeof import('./src/components/common/TagPicker.vue')['default'] TaxSettings: typeof import('./src/components/dashboard/ticket-types/TaxSettings.vue')['default'] TeamCard: typeof import('./src/components/dashboard/teams/TeamCard.vue')['default'] TeamDisplaySettings: typeof import('./src/components/dashboard/teams/settings/TeamDisplaySettings.vue')['default'] diff --git a/dashboard/src/components/common/TagPicker.vue b/dashboard/src/components/common/TagPicker.vue new file mode 100644 index 00000000..8d810589 --- /dev/null +++ b/dashboard/src/components/common/TagPicker.vue @@ -0,0 +1,124 @@ + + + diff --git a/dashboard/src/components/common/filters/ListFilters.vue b/dashboard/src/components/common/filters/ListFilters.vue index dcfed6c9..5dbc7a3d 100644 --- a/dashboard/src/components/common/filters/ListFilters.vue +++ b/dashboard/src/components/common/filters/ListFilters.vue @@ -14,7 +14,12 @@ import { } from "./fields" import FilterChip from "./FilterChip.vue" -const props = defineProps<{ fields: FilterField[]; searchPlaceholder: string }>() +// Two lists on one page would both open on F, so all but one disable the shortcut. +const props = defineProps<{ + fields: FilterField[] + searchPlaceholder: string + disableShortcut?: boolean +}>() const conditions = defineModel({ required: true }) const search = defineModel("search", { required: true }) const order = defineModel("order", { required: true }) @@ -89,7 +94,8 @@ onKeyStroke("f", (event) => { const target = event.target as HTMLElement if (/^(INPUT|TEXTAREA|SELECT)$/.test(target.tagName) || target.isContentEditable) return // A drawer or dialog over the list owns the keyboard. - if (!props.fields.length || document.querySelector('[role="dialog"]')) return + if (props.disableShortcut || !props.fields.length) return + if (document.querySelector('[role="dialog"]')) return if (event.metaKey || event.ctrlKey || event.altKey) return event.preventDefault() menuOpen.value = true @@ -123,9 +129,9 @@ onKeyStroke("f", (event) => {