@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.
- package/package.json +6 -6
- package/src/file-foundation/feature.ts +28 -141
- package/src/tags/__tests__/feature.test.ts +46 -13
- package/src/tags/__tests__/tags.integration.test.ts +66 -4
- package/src/tags/constants.ts +17 -1
- package/src/tags/entity.ts +4 -0
- package/src/tags/feature.ts +18 -7
- package/src/tags/handlers/create-tag.write.ts +1 -1
- package/src/tags/handlers/delete-tag.write.ts +54 -0
- package/src/tags/handlers/update-tag.write.ts +31 -0
- package/src/tags/index.ts +15 -5
- package/src/tags/schemas.ts +25 -6
- package/src/tags/web/__tests__/entity-tags.test.tsx +107 -0
- package/src/tags/web/__tests__/tag-chip.test.ts +34 -0
- package/src/tags/web/__tests__/tag-filter.test.tsx +124 -0
- package/src/tags/web/__tests__/tag-section.test.tsx +51 -77
- package/src/tags/web/__tests__/tags-cell.test.tsx +94 -0
- package/src/tags/web/client-plugin.tsx +20 -1
- package/src/tags/web/entity-tags.tsx +47 -0
- package/src/tags/web/i18n.ts +39 -8
- package/src/tags/web/index.ts +14 -1
- package/src/tags/web/tag-chip.tsx +63 -0
- package/src/tags/web/tag-filter.tsx +100 -0
- package/src/tags/web/tag-manager.tsx +390 -0
- package/src/tags/web/tag-picker.tsx +51 -0
- package/src/tags/web/tag-section.tsx +51 -101
- package/src/tags/web/tags-cell.tsx +46 -0
- package/src/user-data-rights/__tests__/file-binary-forget-cleanup.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-binary-forget-failure.integration.test.ts +0 -1
- package/src/user-data-rights/__tests__/file-storage-unification.integration.test.ts +162 -0
- 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
|
-
|
|
25
|
-
|
|
26
|
-
} from "./handlers/
|
|
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
|
-
|
|
44
|
+
type UpdateTagPayload,
|
|
45
|
+
updateTagPayloadSchema,
|
|
36
46
|
} from "./schemas";
|
package/src/tags/schemas.ts
CHANGED
|
@@ -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
|
-
//
|
|
10
|
-
// tenant:update) + the
|
|
11
|
-
|
|
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
|
|
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
|
|
56
|
-
//
|
|
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
|
|
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("
|
|
130
|
-
// assigned → chip shown
|
|
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("
|
|
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-
|
|
129
|
+
<TagSection entityName="note" entityId="note-1" />
|
|
143
130
|
</Wrapper>,
|
|
144
131
|
);
|
|
145
132
|
|
|
146
|
-
|
|
147
|
-
|
|
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("
|
|
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
|
-
<
|
|
146
|
+
<Wrapper>
|
|
173
147
|
<TagSection entityName="note" entityId="note-1" />
|
|
174
|
-
</
|
|
148
|
+
</Wrapper>,
|
|
175
149
|
);
|
|
176
150
|
|
|
177
|
-
//
|
|
178
|
-
fireEvent.click(screen.getByTestId("
|
|
151
|
+
// t1 assigned → picking [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
|
|
188
|
-
fireEvent.click(screen.getByTestId("
|
|
161
|
+
// t1 assigned → picking [] 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
|
|
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
|
+
});
|