@cosmicdrift/kumiko-bundled-features 0.97.0 → 0.98.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { contrastText } from "../tag-chip";
3
+
4
+ // contrastText is the only non-trivial logic in TagChip: the YIQ pick must put
5
+ // white on dark labels and black on light ones, and reject non-hex input so the
6
+ // chip falls back to neutral instead of rendering an invalid CSS color.
7
+ describe("contrastText", () => {
8
+ test("white text on dark colors", () => {
9
+ expect(contrastText("#000000")).toBe("#ffffff");
10
+ expect(contrastText("#1e3a8a")).toBe("#ffffff"); // dark blue
11
+ });
12
+
13
+ test("black text on light colors", () => {
14
+ expect(contrastText("#ffffff")).toBe("#000000");
15
+ expect(contrastText("#fde68a")).toBe("#000000"); // light yellow
16
+ });
17
+
18
+ test("supports 3-digit shorthand hex", () => {
19
+ expect(contrastText("#fff")).toBe("#000000");
20
+ expect(contrastText("#000")).toBe("#ffffff");
21
+ });
22
+
23
+ test("trims surrounding whitespace", () => {
24
+ expect(contrastText(" #ffffff ")).toBe("#000000");
25
+ });
26
+
27
+ test("returns null for non-hex / malformed input", () => {
28
+ expect(contrastText("")).toBeNull();
29
+ expect(contrastText("rebeccapurple")).toBeNull();
30
+ expect(contrastText("#12")).toBeNull();
31
+ expect(contrastText("#1234")).toBeNull();
32
+ expect(contrastText("22cc88")).toBeNull(); // missing #
33
+ });
34
+ });
@@ -11,7 +11,7 @@ import { TagsHandlers, TagsQueries } from "../../constants";
11
11
  import { defaultTranslations } from "../i18n";
12
12
  import { TagSection, tagSelectionDelta } from "../tag-section";
13
13
 
14
- type TagRow = { id: string; name: string };
14
+ type TagRow = { id: string; name: string; color?: string };
15
15
  type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
16
 
17
17
  let catalogRows: readonly TagRow[] = [];
@@ -44,6 +44,36 @@ mock.module("@cosmicdrift/kumiko-renderer", () => ({
44
44
  useQuery: useQuerySpy,
45
45
  }));
46
46
 
47
+ // The real picker is a Dialog wrapping TagManager (cmdk/Radix popovers) — its
48
+ // interaction is the picker's own test + e2e territory. Here we swap a headless
49
+ // stub that just exposes the onChange contract via two buttons, so we can pin
50
+ // what TagSection does with a new selection (the assign/remove diff) without
51
+ // driving a modal. Same contract as the real picker's onChange.
52
+ const StubPicker = ({
53
+ value,
54
+ onChange,
55
+ }: {
56
+ readonly value: readonly string[];
57
+ readonly onChange: (next: readonly string[]) => void;
58
+ readonly entityType: string;
59
+ readonly open: boolean;
60
+ readonly onOpenChange: (open: boolean) => void;
61
+ }): ReactNode => (
62
+ <div data-testid="stub-picker">
63
+ <button type="button" data-testid="picker-add-t2" onClick={() => onChange([...value, "t2"])}>
64
+ add t2
65
+ </button>
66
+ <button
67
+ type="button"
68
+ data-testid="picker-remove-t1"
69
+ onClick={() => onChange(value.filter((v) => v !== "t1"))}
70
+ >
71
+ remove t1
72
+ </button>
73
+ </div>
74
+ );
75
+ mock.module("../tag-picker", () => ({ TagPicker: StubPicker }));
76
+
47
77
  function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
48
78
  return (
49
79
  <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
@@ -52,51 +82,8 @@ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
52
82
  );
53
83
  }
54
84
 
55
- // The real combobox is cmdk + Radix its popover is e2e/primitive-test
56
- // territory (see note above). To pin the onSelectionChange assign/remove
57
- // wiring we swap in a headless stub that renders one toggle button per option
58
- // and fires onChange with the toggled selection — same contract, no popover.
59
- const StubInput: typeof defaultPrimitives.Input = (props) => {
60
- if (props.kind === "combobox" && props.multiple === true) {
61
- const value = props.value;
62
- return (
63
- <div data-testid="stub-combobox">
64
- {props.options.map((o) => {
65
- const selected = value.includes(o.value);
66
- return (
67
- <button
68
- key={o.value}
69
- type="button"
70
- data-testid={`tag-opt-${o.value}`}
71
- onClick={() =>
72
- props.onChange(selected ? value.filter((v) => v !== o.value) : [...value, o.value])
73
- }
74
- >
75
- {o.label}
76
- </button>
77
- );
78
- })}
79
- </div>
80
- );
81
- }
82
- return <input data-testid={`stub-${props.id}`} />;
83
- };
84
-
85
- function StubComboboxWrapper({ children }: { readonly children: ReactNode }): ReactNode {
86
- return (
87
- <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
88
- <PrimitivesProvider value={{ ...defaultPrimitives, Input: StubInput }}>
89
- {children}
90
- </PrimitivesProvider>
91
- </LocaleProvider>
92
- );
93
- }
94
-
95
- // The combobox's assign/remove toggle drives onChange with the full new
96
- // selection; the component diffs it against the current tags via this helper.
97
- // Popover interaction itself (cmdk + Radix in jsdom) is covered by the
98
- // combobox primitive's own tests + e2e — here we pin the diff that turns a
99
- // selection into assign/remove calls.
85
+ // The picker's onChange drives the full new selection; the section diffs it
86
+ // against the current assignments via this helper to decide assign vs remove.
100
87
  describe("tagSelectionDelta", () => {
101
88
  test("addition only", () => {
102
89
  expect(tagSelectionDelta(["a"], ["a", "b"])).toEqual({ added: ["b"], removed: [] });
@@ -113,9 +100,9 @@ describe("tagSelectionDelta", () => {
113
100
  });
114
101
 
115
102
  describe("TagSection", () => {
116
- test("renders assigned tags as combobox chips", () => {
103
+ test("renders assigned tags as colored chips + an Edit-tags button", () => {
117
104
  catalogRows = [
118
- { id: "t1", name: "important" },
105
+ { id: "t1", name: "important", color: "#ef4444" },
119
106
  { id: "t2", name: "project-x" },
120
107
  ];
121
108
  assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
@@ -126,41 +113,28 @@ describe("TagSection", () => {
126
113
  </Wrapper>,
127
114
  );
128
115
 
129
- expect(screen.getByTestId("combobox-tags-section-select")).toBeTruthy();
130
- // assigned → chip shown in the trigger; unassigned t2 lives in the (closed) dropdown
116
+ expect(screen.getByTestId("tags-section")).toBeTruthy();
117
+ // assigned → chip shown; unassigned t2 is not rendered (it lives in the picker)
131
118
  expect(screen.getByText("important")).toBeTruthy();
132
119
  expect(screen.queryByText("project-x")).toBeNull();
120
+ expect(screen.getByTestId("tags-section-edit")).toBeTruthy();
133
121
  });
134
122
 
135
- test("create-and-attach dispatches create-tag, then assign-tag with the new id", async () => {
136
- catalogRows = [];
123
+ test("no assigned tags renders no chips, just the Edit-tags button", () => {
124
+ catalogRows = [{ id: "t1", name: "important" }];
137
125
  assignmentRows = [];
138
- dispatchSpy.mockClear();
139
126
 
140
127
  render(
141
128
  <Wrapper>
142
- <TagSection entityName="note" entityId="note-9" />
129
+ <TagSection entityName="note" entityId="note-1" />
143
130
  </Wrapper>,
144
131
  );
145
132
 
146
- fireEvent.change(document.getElementById("tags-section-new") as HTMLInputElement, {
147
- target: { value: "urgent" },
148
- });
149
- fireEvent.click(screen.getByTestId("tags-section-create"));
150
-
151
- await waitFor(() =>
152
- expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.createTag, { name: "urgent" }),
153
- );
154
- await waitFor(() =>
155
- expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
156
- tagId: "tag-new",
157
- entityType: "note",
158
- entityId: "note-9",
159
- }),
160
- );
133
+ expect(screen.queryByTestId("tag-chip")).toBeNull();
134
+ expect(screen.getByTestId("tags-section-edit")).toBeTruthy();
161
135
  });
162
136
 
163
- test("#524/3: selection change dispatches assign for additions, remove for removals", async () => {
137
+ test("picker selection dispatches assign for additions, remove for removals", async () => {
164
138
  catalogRows = [
165
139
  { id: "t1", name: "important" },
166
140
  { id: "t2", name: "project-x" },
@@ -169,13 +143,13 @@ describe("TagSection", () => {
169
143
  dispatchSpy.mockClear();
170
144
 
171
145
  render(
172
- <StubComboboxWrapper>
146
+ <Wrapper>
173
147
  <TagSection entityName="note" entityId="note-1" />
174
- </StubComboboxWrapper>,
148
+ </Wrapper>,
175
149
  );
176
150
 
177
- // t2 is unselected toggling it on adds it → assign-tag with t2
178
- fireEvent.click(screen.getByTestId("tag-opt-t2"));
151
+ // t1 assignedpicking [t1, t2] adds t2 → assign-tag
152
+ fireEvent.click(screen.getByTestId("picker-add-t2"));
179
153
  await waitFor(() =>
180
154
  expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
181
155
  tagId: "t2",
@@ -184,8 +158,8 @@ describe("TagSection", () => {
184
158
  }),
185
159
  );
186
160
 
187
- // t1 is selected toggling it off removes it → remove-tag with t1
188
- fireEvent.click(screen.getByTestId("tag-opt-t1"));
161
+ // t1 assignedpicking [] removes t1 → remove-tag
162
+ fireEvent.click(screen.getByTestId("picker-remove-t1"));
189
163
  await waitFor(() =>
190
164
  expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
191
165
  tagId: "t1",
@@ -195,7 +169,7 @@ describe("TagSection", () => {
195
169
  );
196
170
  });
197
171
 
198
- test("create-mode (no entityId yet) shows the save-first hint instead of the manager", () => {
172
+ test("create-mode (no entityId yet) shows the save-first hint instead of the section", () => {
199
173
  render(
200
174
  <Wrapper>
201
175
  <TagSection entityName="note" entityId={null} />
@@ -1,8 +1,15 @@
1
1
  // @runtime client
2
2
 
3
3
  import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
4
- import { TAGS_FEATURE_NAME, TAGS_SECTION_EXTENSION_NAME } from "../constants";
4
+ import {
5
+ TAGS_FEATURE_NAME,
6
+ TAGS_FILTER_EXTENSION_NAME,
7
+ TAGS_SCREEN_ID,
8
+ TAGS_SECTION_EXTENSION_NAME,
9
+ } from "../constants";
5
10
  import { defaultTranslations } from "./i18n";
11
+ import { TagFilter } from "./tag-filter";
12
+ import { TagManager } from "./tag-manager";
6
13
  import { TagSection } from "./tag-section";
7
14
 
8
15
  export function tagsClient(): ClientFeatureDefinition {
@@ -10,6 +17,12 @@ export function tagsClient(): ClientFeatureDefinition {
10
17
  name: TAGS_FEATURE_NAME,
11
18
  extensionSectionComponents: {
12
19
  [TAGS_SECTION_EXTENSION_NAME]: TagSection,
20
+ // Header-slot tag filter for any entityList toolbar.
21
+ [TAGS_FILTER_EXTENSION_NAME]: TagFilter,
22
+ },
23
+ // Standalone Tags management screen (custom screen → TagManager).
24
+ components: {
25
+ [TAGS_SCREEN_ID]: TagManager,
13
26
  },
14
27
  translations: defaultTranslations,
15
28
  };
@@ -0,0 +1,47 @@
1
+ // @runtime client
2
+ // EntityTags — read-only colored tag row for ANY entity. Drop it on a card or
3
+ // detail view: <EntityTags entityName="note" entityId={id} />. Loads the
4
+ // catalog (for name+color) and the entity's assignments, renders a TagChip per
5
+ // assigned tag. Renders nothing when the entity has no tags.
6
+
7
+ import { useQuery } from "@cosmicdrift/kumiko-renderer";
8
+ import type { ReactNode } from "react";
9
+ import { TagsQueries } from "../constants";
10
+ import { TagChip } from "./tag-chip";
11
+
12
+ type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
13
+ type AssignmentRow = {
14
+ readonly tagId: string;
15
+ readonly entityType: string;
16
+ readonly entityId: string;
17
+ };
18
+
19
+ export function EntityTags({
20
+ entityName,
21
+ entityId,
22
+ }: {
23
+ readonly entityName: string;
24
+ readonly entityId: string | null;
25
+ }): ReactNode {
26
+ const enabled = entityId !== null;
27
+ const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {}, { enabled });
28
+ const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(
29
+ TagsQueries.assignmentList,
30
+ { filter: { field: "entityId", op: "eq", value: entityId } },
31
+ { enabled },
32
+ );
33
+
34
+ if (entityId === null) return null;
35
+ const byId = new Map((catalog.data?.rows ?? []).map((t) => [t.id, t]));
36
+ const assigned = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
37
+ if (assigned.length === 0) return null;
38
+
39
+ return (
40
+ <div data-testid="entity-tags" className="flex flex-wrap gap-1">
41
+ {assigned.map((a) => {
42
+ const tag = byId.get(a.tagId);
43
+ return <TagChip key={a.tagId} name={tag?.name ?? a.tagId} color={tag?.color} />;
44
+ })}
45
+ </div>
46
+ );
47
+ }
@@ -9,21 +9,52 @@ export const defaultTranslations: TranslationsByLocale = {
9
9
  de: {
10
10
  "tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
11
11
  "tags.section.loading": "Lädt…",
12
- "tags.section.label": "Tags",
13
- "tags.section.placeholder": "Tags auswählen…",
14
12
  "tags.section.empty": "Keine Tags gefunden.",
15
- "tags.section.newLabel": "Neuer Tag",
16
- "tags.section.create": "Tag anlegen & zuweisen",
17
13
  "tags.section.working": "Speichert…",
14
+ "tags.section.none": "Keine Tags",
15
+ "tags.section.edit": "Tags bearbeiten",
16
+ "tags.manage.newLabel": "Neuer Tag",
17
+ "tags.manage.namePlaceholder": "Tag-Name",
18
+ "tags.manage.scopeLabel": "Geltungsbereich (Entity-Typ, leer = global)",
19
+ "tags.manage.scopePlaceholder": "z. B. note (leer = überall)",
20
+ "tags.manage.create": "Tag anlegen",
21
+ "tags.manage.edit": "Bearbeiten",
22
+ "tags.manage.save": "Speichern",
23
+ "tags.manage.cancel": "Abbrechen",
24
+ "tags.manage.delete": "Löschen",
25
+ "tags.manage.toggle": "Tag umschalten",
26
+ "tags.manage.usage": "{count}×",
27
+ "tags.manage.deleteConfirmTitle": "Tag „{name}“ löschen?",
28
+ "tags.manage.deleteConfirmDesc":
29
+ "Entfernt ihn von {count} Objekten. Das lässt sich nicht rückgängig machen.",
30
+ "tags.picker.title": "Tags",
31
+ "tags.picker.done": "Fertig",
32
+ "tags.filter.label": "Nach Tag filtern",
33
+ "tags.filter.active": "Tags: {count}",
18
34
  },
19
35
  en: {
20
36
  "tags.section.createMode": "Save the entity first to add tags.",
21
37
  "tags.section.loading": "Loading…",
22
- "tags.section.label": "Tags",
23
- "tags.section.placeholder": "Select tags…",
24
38
  "tags.section.empty": "No tags found.",
25
- "tags.section.newLabel": "New tag",
26
- "tags.section.create": "Create & attach tag",
27
39
  "tags.section.working": "Saving…",
40
+ "tags.section.none": "No tags",
41
+ "tags.section.edit": "Edit tags",
42
+ "tags.manage.newLabel": "New label",
43
+ "tags.manage.namePlaceholder": "Label name",
44
+ "tags.manage.scopeLabel": "Scope (entity type, empty = global)",
45
+ "tags.manage.scopePlaceholder": "e.g. note (empty = everywhere)",
46
+ "tags.manage.create": "Create label",
47
+ "tags.manage.edit": "Edit",
48
+ "tags.manage.save": "Save",
49
+ "tags.manage.cancel": "Cancel",
50
+ "tags.manage.delete": "Delete",
51
+ "tags.manage.toggle": "Toggle label",
52
+ "tags.manage.usage": "{count}×",
53
+ "tags.manage.deleteConfirmTitle": "Delete label “{name}”?",
54
+ "tags.manage.deleteConfirmDesc": "Removes it from {count} objects. This can't be undone.",
55
+ "tags.picker.title": "Tags",
56
+ "tags.picker.done": "Done",
57
+ "tags.filter.label": "Filter by tag",
58
+ "tags.filter.active": "Tags: {count}",
28
59
  },
29
60
  };
@@ -1,4 +1,15 @@
1
1
  // @runtime client
2
- export { TAGS_SECTION_EXTENSION_NAME, TagsHandlers, TagsQueries } from "../constants";
2
+ export {
3
+ TAGS_FILTER_EXTENSION_NAME,
4
+ TAGS_SCREEN_ID,
5
+ TAGS_SECTION_EXTENSION_NAME,
6
+ TagsHandlers,
7
+ TagsQueries,
8
+ } from "../constants";
3
9
  export { tagsClient } from "./client-plugin";
10
+ export { EntityTags } from "./entity-tags";
11
+ export { contrastText, TagChip } from "./tag-chip";
12
+ export { TagFilter } from "./tag-filter";
13
+ export { TagManager } from "./tag-manager";
14
+ export { TagPicker } from "./tag-picker";
4
15
  export { TagSection } from "./tag-section";
@@ -0,0 +1,63 @@
1
+ // @runtime client
2
+ // TagChip — read-only colored label pill, GitLab-labels style. The tag's `color`
3
+ // (a hex like "#22cc88") drives the background; the text color is chosen for
4
+ // contrast (YIQ luminance) so light and dark labels both stay legible. A tag
5
+ // with no usable color falls back to a neutral chip. Self-contained inline
6
+ // styles (pattern from config-source-badge.tsx) so it renders the same in the
7
+ // picker, on cards and in screenshots without depending on Tailwind tokens.
8
+
9
+ import type { CSSProperties, ReactNode } from "react";
10
+
11
+ // YIQ perceived brightness → black text on light backgrounds, white on dark.
12
+ // Returns null for anything that isn't a #rgb/#rrggbb hex so the caller can use
13
+ // its neutral fallback (color is a free-text UI hint, never validated).
14
+ export function contrastText(color: string): "#000000" | "#ffffff" | null {
15
+ const rgb = hexToRgb(color);
16
+ if (rgb === null) return null;
17
+ const yiq = (rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000;
18
+ return yiq >= 128 ? "#000000" : "#ffffff";
19
+ }
20
+
21
+ function hexToRgb(color: string): { r: number; g: number; b: number } | null {
22
+ const match = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.exec(color.trim());
23
+ if (match === null) return null;
24
+ const short = match[1] as string;
25
+ const hex =
26
+ short.length === 3
27
+ ? `${short[0]}${short[0]}${short[1]}${short[1]}${short[2]}${short[2]}`
28
+ : short;
29
+ return {
30
+ r: Number.parseInt(hex.slice(0, 2), 16),
31
+ g: Number.parseInt(hex.slice(2, 4), 16),
32
+ b: Number.parseInt(hex.slice(4, 6), 16),
33
+ };
34
+ }
35
+
36
+ const BASE_STYLE: CSSProperties = {
37
+ display: "inline-flex",
38
+ alignItems: "center",
39
+ padding: "0 8px",
40
+ fontSize: "12px",
41
+ fontWeight: 500,
42
+ lineHeight: "20px",
43
+ borderRadius: "9999px",
44
+ whiteSpace: "nowrap",
45
+ };
46
+
47
+ const NEUTRAL = { backgroundColor: "#e5e7eb", color: "#374151" } as const;
48
+
49
+ export function TagChip({
50
+ name,
51
+ color,
52
+ }: {
53
+ readonly name: string;
54
+ readonly color?: string | null;
55
+ }): ReactNode {
56
+ const fg = color != null && color !== "" ? contrastText(color) : null;
57
+ const palette = fg !== null && color != null ? { backgroundColor: color, color: fg } : NEUTRAL;
58
+ return (
59
+ <span data-testid="tag-chip" style={{ ...BASE_STYLE, ...palette }}>
60
+ {name}
61
+ </span>
62
+ );
63
+ }
@@ -0,0 +1,88 @@
1
+ // @runtime client
2
+ // TagFilter — a drop-in tag filter for ANY entityList toolbar. Register it as
3
+ // the list's header slot (`screen.slots.header`) and mark `id` filterable is NOT
4
+ // needed — the renderer passes this control the list's screenId, and picking
5
+ // tags resolves the matching row ids and narrows the list via an id-set URL
6
+ // filter (`<screenId>.f.id=…`, applied as `{ field: "id", op: "in" }`). No host
7
+ // schema change: any list gets tag-filtering by mounting this in its header.
8
+ //
9
+ // ponytail: resolves ids from the assignment list (first 500 rows) + an id-IN
10
+ // URL filter — fine for typical tag volumes. For huge assignment sets add a
11
+ // server-side `entitiesByTag` query + a server-side join filter instead.
12
+
13
+ import {
14
+ useListUrlState,
15
+ usePrimitives,
16
+ useQuery,
17
+ useTranslation,
18
+ } from "@cosmicdrift/kumiko-renderer";
19
+ import { type ReactNode, useState } from "react";
20
+ import { TagsQueries } from "../constants";
21
+ import { TagPicker } from "./tag-picker";
22
+
23
+ // Tags chosen but zero matching entities → filter to a value that matches no row
24
+ // (so the list shows empty), instead of clearing the filter (which shows all).
25
+ const NO_MATCH = "__tags_no_match__";
26
+
27
+ type AssignmentRow = {
28
+ readonly tagId: string;
29
+ readonly entityType: string;
30
+ readonly entityId: string;
31
+ };
32
+
33
+ export function TagFilter({
34
+ entityName,
35
+ screenId,
36
+ }: {
37
+ readonly entityName: string;
38
+ readonly entityId?: string | null;
39
+ readonly screenId?: string;
40
+ }): ReactNode {
41
+ const { Button } = usePrimitives();
42
+ const t = useTranslation();
43
+ const [open, setOpen] = useState(false);
44
+ const [selected, setSelected] = useState<readonly string[]>([]);
45
+ const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {
46
+ limit: 500,
47
+ });
48
+ // Unconditional (Rules of Hooks). The renderer always passes a screenId in the
49
+ // header slot; the fallback namespace is inert if it ever runs standalone.
50
+ const urlState = useListUrlState(screenId ?? "__tag-filter__");
51
+
52
+ const applyFilter = (tagIds: readonly string[]): void => {
53
+ setSelected(tagIds);
54
+ if (tagIds.length === 0) {
55
+ urlState.setFilter("id", []);
56
+ return;
57
+ }
58
+ const wanted = new Set(tagIds);
59
+ const ids = [
60
+ ...new Set(
61
+ (assignments.data?.rows ?? [])
62
+ .filter((a) => a.entityType === entityName && wanted.has(a.tagId))
63
+ .map((a) => a.entityId),
64
+ ),
65
+ ];
66
+ urlState.setFilter("id", ids.length > 0 ? ids : [NO_MATCH]);
67
+ };
68
+
69
+ const label =
70
+ selected.length > 0
71
+ ? t("tags.filter.active", { count: selected.length })
72
+ : t("tags.filter.label");
73
+
74
+ return (
75
+ <>
76
+ <Button variant="secondary" onClick={() => setOpen(true)} testId="tag-filter-open">
77
+ {label}
78
+ </Button>
79
+ <TagPicker
80
+ entityType={entityName}
81
+ value={selected}
82
+ onChange={applyFilter}
83
+ open={open}
84
+ onOpenChange={setOpen}
85
+ />
86
+ </>
87
+ );
88
+ }