@cosmicdrift/kumiko-bundled-features 0.97.1 → 0.100.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.
Files changed (31) hide show
  1. package/package.json +6 -6
  2. package/src/file-foundation/feature.ts +28 -141
  3. package/src/tags/__tests__/feature.test.ts +46 -13
  4. package/src/tags/__tests__/tags.integration.test.ts +66 -4
  5. package/src/tags/constants.ts +17 -1
  6. package/src/tags/entity.ts +4 -0
  7. package/src/tags/feature.ts +18 -7
  8. package/src/tags/handlers/create-tag.write.ts +1 -1
  9. package/src/tags/handlers/delete-tag.write.ts +54 -0
  10. package/src/tags/handlers/update-tag.write.ts +31 -0
  11. package/src/tags/index.ts +15 -5
  12. package/src/tags/schemas.ts +25 -6
  13. package/src/tags/web/__tests__/entity-tags.test.tsx +107 -0
  14. package/src/tags/web/__tests__/tag-chip.test.ts +34 -0
  15. package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
  16. package/src/tags/web/__tests__/tag-section.test.tsx +51 -77
  17. package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
  18. package/src/tags/web/client-plugin.tsx +20 -1
  19. package/src/tags/web/entity-tags.tsx +47 -0
  20. package/src/tags/web/i18n.ts +39 -8
  21. package/src/tags/web/index.ts +14 -1
  22. package/src/tags/web/tag-chip.tsx +63 -0
  23. package/src/tags/web/tag-filter.tsx +100 -0
  24. package/src/tags/web/tag-manager.tsx +390 -0
  25. package/src/tags/web/tag-picker.tsx +51 -0
  26. package/src/tags/web/tag-section.tsx +51 -101
  27. package/src/tags/web/tags-cell.tsx +46 -0
  28. package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
  29. package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
  30. package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
  31. package/src/tags/handlers/rename-tag.write.ts +0 -26
@@ -0,0 +1,31 @@
1
+ import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
2
+ import { DEFAULT_TAG_ACCESS } from "../constants";
3
+ import { tagExecutor } from "../executor";
4
+ import { type UpdateTagPayload, updateTagPayloadSchema } from "../schemas";
5
+
6
+ // update-tag — edits a catalog tag (rename / recolor / re-scope). Optimistic-
7
+ // locked: the client sends the `version` it read (mirrors tenant:update). Only
8
+ // the fields present in the payload go into `changes`, and the executor merges
9
+ // shallowly, so any omitted field is preserved (color/scope accept "" to clear).
10
+ // A stale version returns version_conflict; the UI refetches and retries.
11
+ export function createUpdateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
12
+ return {
13
+ name: "update-tag",
14
+ schema: updateTagPayloadSchema,
15
+ access,
16
+ handler: async (event, ctx) => {
17
+ const payload = event.payload as UpdateTagPayload; // @cast-boundary engine-payload
18
+ const changes: Record<string, unknown> = {};
19
+ if (payload.name !== undefined) changes["name"] = payload.name;
20
+ if (payload.color !== undefined) changes["color"] = payload.color;
21
+ if (payload.scope !== undefined) changes["scope"] = payload.scope;
22
+ return tagExecutor.update(
23
+ { id: payload.id, version: payload.version, changes },
24
+ event.user,
25
+ ctx.db,
26
+ );
27
+ },
28
+ };
29
+ }
30
+
31
+ export const updateTagHandler: WriteHandlerDef = createUpdateTagHandler();
package/src/tags/index.ts CHANGED
@@ -2,7 +2,11 @@ export { tagAssignmentAggregateId } from "./aggregate-id";
2
2
  export {
3
3
  DEFAULT_TAG_ACCESS,
4
4
  DEFAULT_TAG_ROLES,
5
+ TAGS_COLUMN_RENDERER_NAME,
5
6
  TAGS_FEATURE_NAME,
7
+ TAGS_FILTER_EXTENSION_NAME,
8
+ TAGS_SCREEN_ID,
9
+ TAGS_SECTION_EXTENSION_NAME,
6
10
  TagsHandlers,
7
11
  TagsQueries,
8
12
  } from "./constants";
@@ -16,21 +20,27 @@ export {
16
20
  createCreateTagHandler,
17
21
  createTagHandler,
18
22
  } from "./handlers/create-tag.write";
23
+ export {
24
+ createDeleteTagHandler,
25
+ deleteTagHandler,
26
+ } from "./handlers/delete-tag.write";
19
27
  export {
20
28
  createRemoveTagHandler,
21
29
  removeTagHandler,
22
30
  } from "./handlers/remove-tag.write";
23
31
  export {
24
- createRenameTagHandler,
25
- renameTagHandler,
26
- } from "./handlers/rename-tag.write";
32
+ createUpdateTagHandler,
33
+ updateTagHandler,
34
+ } from "./handlers/update-tag.write";
27
35
  export {
28
36
  type AssignTagPayload,
29
37
  assignTagPayloadSchema,
30
38
  type CreateTagPayload,
31
39
  createTagPayloadSchema,
40
+ type DeleteTagPayload,
41
+ deleteTagPayloadSchema,
32
42
  type RemoveTagPayload,
33
- type RenameTagPayload,
34
43
  removeTagPayloadSchema,
35
- renameTagPayloadSchema,
44
+ type UpdateTagPayload,
45
+ updateTagPayloadSchema,
36
46
  } from "./schemas";
@@ -3,17 +3,36 @@ import { z } from "zod";
3
3
  export const createTagPayloadSchema = z.object({
4
4
  name: z.string().min(1).max(64),
5
5
  color: z.string().max(32).optional(),
6
+ scope: z.string().max(64).optional(),
6
7
  });
7
8
  export type CreateTagPayload = z.infer<typeof createTagPayloadSchema>;
8
9
 
9
- // rename-tag — id + the version the client read (optimistic lock, mirrors
10
- // tenant:update) + the new name. The executor merges shallowly, so color stays.
11
- export const renameTagPayloadSchema = z.object({
10
+ // update-tag — id + the version the client read (optimistic lock, mirrors
11
+ // tenant:update) + the fields to change. name/color/scope are each optional so
12
+ // the management UI can rename, recolor or re-scope independently; the executor
13
+ // shallow-merges, so any field left undefined is preserved. color/scope accept ""
14
+ // to clear them. At least one mutable field must be present (no-op guard).
15
+ export const updateTagPayloadSchema = z
16
+ .object({
17
+ id: z.string().min(1),
18
+ version: z.number().int().nonnegative(),
19
+ name: z.string().min(1).max(64).optional(),
20
+ color: z.string().max(32).optional(),
21
+ scope: z.string().max(64).optional(),
22
+ })
23
+ .refine((p) => p.name !== undefined || p.color !== undefined || p.scope !== undefined, {
24
+ message: "update-tag needs at least one of name, color or scope",
25
+ });
26
+ export type UpdateTagPayload = z.infer<typeof updateTagPayloadSchema>;
27
+
28
+ // delete-tag — hard-deletes the catalog tag AND cascades a soft-delete over
29
+ // every assignment carrying it (no FK, so the handler does the cascade). No
30
+ // version: deleting a label is a destructive "make it gone" intent — last writer
31
+ // wins toward deletion rather than 409-ing on a concurrent rename. Idempotent.
32
+ export const deleteTagPayloadSchema = z.object({
12
33
  id: z.string().min(1),
13
- version: z.number().int().nonnegative(),
14
- name: z.string().min(1).max(64),
15
34
  });
16
- export type RenameTagPayload = z.infer<typeof renameTagPayloadSchema>;
35
+ export type DeleteTagPayload = z.infer<typeof deleteTagPayloadSchema>;
17
36
 
18
37
  // assign + remove share the (tag, entity) reference shape.
19
38
  const entityTagRef = {
@@ -0,0 +1,107 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsQueries } from "../../constants";
11
+ import { EntityTags } from "../entity-tags";
12
+ import { defaultTranslations } from "../i18n";
13
+
14
+ type TagRow = { id: string; name: string; color?: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+
20
+ beforeEach(() => {
21
+ catalogRows = [];
22
+ assignmentRows = [];
23
+ });
24
+
25
+ const useQuerySpy = mock((type: string) => ({
26
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(async () => {}),
30
+ }));
31
+
32
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
33
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
34
+ ...actual_renderer,
35
+ useQuery: useQuerySpy,
36
+ }));
37
+
38
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
39
+ return (
40
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
41
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
42
+ </LocaleProvider>
43
+ );
44
+ }
45
+
46
+ describe("EntityTags", () => {
47
+ test("renders a chip per assigned tag, joined to the catalog by id", () => {
48
+ catalogRows = [
49
+ { id: "t1", name: "important", color: "#ef4444" },
50
+ { id: "t2", name: "project-x", color: "#3b82f6" },
51
+ ];
52
+ assignmentRows = [
53
+ { tagId: "t1", entityType: "note", entityId: "n1" },
54
+ { tagId: "t2", entityType: "note", entityId: "n1" },
55
+ ];
56
+
57
+ render(
58
+ <Wrapper>
59
+ <EntityTags entityName="note" entityId="n1" />
60
+ </Wrapper>,
61
+ );
62
+
63
+ expect(screen.getByTestId("entity-tags")).toBeTruthy();
64
+ expect(screen.getByText("important")).toBeTruthy();
65
+ expect(screen.getByText("project-x")).toBeTruthy();
66
+ });
67
+
68
+ test("renders nothing when the entity has no assignments", () => {
69
+ catalogRows = [{ id: "t1", name: "important" }];
70
+ assignmentRows = [];
71
+
72
+ render(
73
+ <Wrapper>
74
+ <EntityTags entityName="note" entityId="n1" />
75
+ </Wrapper>,
76
+ );
77
+
78
+ expect(screen.queryByTestId("entity-tags")).toBeNull();
79
+ expect(screen.queryByTestId("tag-chip")).toBeNull();
80
+ });
81
+
82
+ test("ignores assignments belonging to a different entityType", () => {
83
+ catalogRows = [{ id: "t1", name: "important" }];
84
+ assignmentRows = [{ tagId: "t1", entityType: "invoice", entityId: "n1" }];
85
+
86
+ render(
87
+ <Wrapper>
88
+ <EntityTags entityName="note" entityId="n1" />
89
+ </Wrapper>,
90
+ );
91
+
92
+ expect(screen.queryByTestId("entity-tags")).toBeNull();
93
+ });
94
+
95
+ test("renders nothing for a not-yet-saved entity (entityId null)", () => {
96
+ catalogRows = [{ id: "t1", name: "important" }];
97
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
98
+
99
+ render(
100
+ <Wrapper>
101
+ <EntityTags entityName="note" entityId={null} />
102
+ </Wrapper>,
103
+ );
104
+
105
+ expect(screen.queryByTestId("entity-tags")).toBeNull();
106
+ });
107
+ });
@@ -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
+ });
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { fireEvent, render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsQueries } from "../../constants";
11
+ import { defaultTranslations } from "../i18n";
12
+ import { TagFilter } from "../tag-filter";
13
+
14
+ type TagRow = { id: string; name: string; color?: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+ const setFilterSpy = mock((_field: string, _values: readonly string[]) => {});
20
+
21
+ beforeEach(() => {
22
+ catalogRows = [
23
+ { id: "t1", name: "urgent", color: "#ef4444" },
24
+ { id: "t2", name: "backend", color: "#3b82f6" },
25
+ ];
26
+ assignmentRows = [
27
+ { tagId: "t1", entityType: "note", entityId: "n1" },
28
+ { tagId: "t2", entityType: "note", entityId: "n2" },
29
+ ];
30
+ setFilterSpy.mockClear();
31
+ });
32
+
33
+ const useQuerySpy = mock((type: string) => ({
34
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
35
+ loading: false,
36
+ error: null,
37
+ refetch: mock(async () => {}),
38
+ }));
39
+
40
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
41
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
42
+ ...actual_renderer,
43
+ useQuery: useQuerySpy,
44
+ useListUrlState: () => ({
45
+ sort: null,
46
+ q: "",
47
+ page: 1,
48
+ filters: {},
49
+ setSort: mock(() => {}),
50
+ setQ: mock(() => {}),
51
+ setPage: mock(() => {}),
52
+ setFilter: setFilterSpy,
53
+ clearFilters: mock(() => {}),
54
+ }),
55
+ }));
56
+
57
+ // Headless picker stub: a button that reports a tag selection back through the
58
+ // real onChange contract — no Dialog/cmdk popover to drive.
59
+ const StubPicker = ({
60
+ onChange,
61
+ }: {
62
+ readonly value: readonly string[];
63
+ readonly onChange: (next: readonly string[]) => void;
64
+ readonly entityType: string;
65
+ readonly open: boolean;
66
+ readonly onOpenChange: (open: boolean) => void;
67
+ }): ReactNode => (
68
+ <button type="button" data-testid="picker-pick-t1" onClick={() => onChange(["t1"])}>
69
+ pick urgent
70
+ </button>
71
+ );
72
+ mock.module("../tag-picker", () => ({ TagPicker: StubPicker }));
73
+
74
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
75
+ return (
76
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
77
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
78
+ </LocaleProvider>
79
+ );
80
+ }
81
+
82
+ describe("TagFilter", () => {
83
+ test("idle → just the filter button, no chips, no clear", () => {
84
+ render(
85
+ <Wrapper>
86
+ <TagFilter entityName="note" screenId="note-list" />
87
+ </Wrapper>,
88
+ );
89
+ expect(screen.getByTestId("tag-filter-open")).toBeTruthy();
90
+ expect(screen.queryByTestId("tag-chip")).toBeNull();
91
+ expect(screen.queryByTestId("tag-filter-clear")).toBeNull();
92
+ });
93
+
94
+ test("picking a tag → narrows the list to its entity ids + shows the selected chip + clear", () => {
95
+ render(
96
+ <Wrapper>
97
+ <TagFilter entityName="note" screenId="note-list" />
98
+ </Wrapper>,
99
+ );
100
+
101
+ fireEvent.click(screen.getByTestId("picker-pick-t1"));
102
+
103
+ // urgent (t1) is assigned to n1 → list narrows to that id set
104
+ expect(setFilterSpy).toHaveBeenCalledWith("id", ["n1"]);
105
+ // the active selection is visible as a chip + a clear affordance
106
+ expect(screen.getByText("urgent")).toBeTruthy();
107
+ expect(screen.getByTestId("tag-filter-clear")).toBeTruthy();
108
+ });
109
+
110
+ test("clear → drops the filter and the chip", () => {
111
+ render(
112
+ <Wrapper>
113
+ <TagFilter entityName="note" screenId="note-list" />
114
+ </Wrapper>,
115
+ );
116
+ fireEvent.click(screen.getByTestId("picker-pick-t1"));
117
+ setFilterSpy.mockClear();
118
+
119
+ fireEvent.click(screen.getByTestId("tag-filter-clear"));
120
+
121
+ expect(setFilterSpy).toHaveBeenCalledWith("id", []);
122
+ expect(screen.queryByText("urgent")).toBeNull();
123
+ });
124
+ });
@@ -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} />
@@ -0,0 +1,94 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+ import {
3
+ createStaticLocaleResolver,
4
+ LocaleProvider,
5
+ PrimitivesProvider,
6
+ } from "@cosmicdrift/kumiko-renderer";
7
+ import { defaultPrimitives } from "@cosmicdrift/kumiko-renderer-web";
8
+ import { render, screen } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsQueries } from "../../constants";
11
+ import { defaultTranslations } from "../i18n";
12
+ import { TagsCell } from "../tags-cell";
13
+
14
+ type TagRow = { id: string; name: string; color?: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+
20
+ beforeEach(() => {
21
+ catalogRows = [];
22
+ assignmentRows = [];
23
+ });
24
+
25
+ const useQuerySpy = mock((type: string) => ({
26
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
27
+ loading: false,
28
+ error: null,
29
+ refetch: mock(async () => {}),
30
+ }));
31
+
32
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
33
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
34
+ ...actual_renderer,
35
+ useQuery: useQuerySpy,
36
+ }));
37
+
38
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
39
+ return (
40
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
41
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
42
+ </LocaleProvider>
43
+ );
44
+ }
45
+
46
+ function renderCell(rowId: string): void {
47
+ render(
48
+ <Wrapper>
49
+ <TagsCell value={rowId} row={{ id: rowId }} column={{ field: "tags" }} />
50
+ </Wrapper>,
51
+ );
52
+ }
53
+
54
+ describe("TagsCell", () => {
55
+ test("renders a chip per tag assigned to the row's id, joined to the catalog", () => {
56
+ catalogRows = [
57
+ { id: "t1", name: "important", color: "#ef4444" },
58
+ { id: "t2", name: "project-x", color: "#3b82f6" },
59
+ ];
60
+ assignmentRows = [
61
+ { tagId: "t1", entityType: "note", entityId: "n1" },
62
+ { tagId: "t2", entityType: "note", entityId: "n1" },
63
+ { tagId: "t1", entityType: "note", entityId: "n2" }, // a different row
64
+ ];
65
+
66
+ renderCell("n1");
67
+
68
+ expect(screen.getByTestId("tags-cell")).toBeTruthy();
69
+ expect(screen.getByText("important")).toBeTruthy();
70
+ expect(screen.getByText("project-x")).toBeTruthy();
71
+ });
72
+
73
+ test("renders nothing when the row has no assigned tags", () => {
74
+ catalogRows = [{ id: "t1", name: "important" }];
75
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "other-row" }];
76
+
77
+ renderCell("n1");
78
+
79
+ expect(screen.queryByTestId("tags-cell")).toBeNull();
80
+ });
81
+
82
+ test("renders nothing for an empty row id", () => {
83
+ catalogRows = [{ id: "t1", name: "important" }];
84
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "n1" }];
85
+
86
+ render(
87
+ <Wrapper>
88
+ <TagsCell value="" row={{}} column={{ field: "tags" }} />
89
+ </Wrapper>,
90
+ );
91
+
92
+ expect(screen.queryByTestId("tags-cell")).toBeNull();
93
+ });
94
+ });