@cosmicdrift/kumiko-bundled-features 0.61.0 → 0.63.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.61.0",
3
+ "version": "0.63.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -31,6 +31,7 @@
31
31
  "./custom-fields": "./src/custom-fields/index.ts",
32
32
  "./custom-fields/web": "./src/custom-fields/web/index.ts",
33
33
  "./tags": "./src/tags/index.ts",
34
+ "./tags/web": "./src/tags/web/index.ts",
34
35
  "./billing-foundation": "./src/billing-foundation/index.ts",
35
36
  "./subscription-stripe": "./src/subscription-stripe/index.ts",
36
37
  "./subscription-mollie": "./src/subscription-mollie/index.ts",
@@ -8,6 +8,12 @@ import type { AccessRule } from "@cosmicdrift/kumiko-framework/engine";
8
8
 
9
9
  export const TAGS_FEATURE_NAME = "tags";
10
10
 
11
+ // Registry name for the drop-in <TagSection> component. Apps reference it in a
12
+ // screen schema via `component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } }`
13
+ // after mounting tagsClient(); the component is also importable directly for
14
+ // standalone use from `@cosmicdrift/kumiko-bundled-features/tags/web`.
15
+ export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
16
+
11
17
  // Qualified handler names (QN format: scope:type:name). Clients reference the
12
18
  // object instead of magic strings (mirror custom-fields' Handlers/Queries).
13
19
  export const TagsHandlers = {
@@ -0,0 +1,126 @@
1
+ import { 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, waitFor } from "@testing-library/react";
9
+ import type { ReactNode } from "react";
10
+ import { TagsHandlers, TagsQueries } from "../../constants";
11
+ import { defaultTranslations } from "../i18n";
12
+ import { TagSection } from "../tag-section";
13
+
14
+ type TagRow = { id: string; name: string };
15
+ type AssignmentRow = { tagId: string; entityType: string; entityId: string };
16
+
17
+ let catalogRows: readonly TagRow[] = [];
18
+ let assignmentRows: readonly AssignmentRow[] = [];
19
+
20
+ // createTag returns the new id; assign/remove return data-less success.
21
+ const dispatchSpy = mock(async (type: string) =>
22
+ type === TagsHandlers.createTag
23
+ ? { isSuccess: true, data: { id: "tag-new" } }
24
+ : { isSuccess: true, data: undefined },
25
+ );
26
+
27
+ // useQuery is called twice (catalog + assignments) — branch on the QN.
28
+ const useQuerySpy = mock((type: string) => ({
29
+ data: type === TagsQueries.tagList ? { rows: catalogRows } : { rows: assignmentRows },
30
+ loading: false,
31
+ error: null,
32
+ refetch: mock(async () => {}),
33
+ }));
34
+
35
+ const actual_renderer = await import("@cosmicdrift/kumiko-renderer");
36
+ mock.module("@cosmicdrift/kumiko-renderer", () => ({
37
+ ...actual_renderer,
38
+ useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
39
+ useQuery: useQuerySpy,
40
+ }));
41
+
42
+ function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
43
+ return (
44
+ <LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
45
+ <PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
46
+ </LocaleProvider>
47
+ );
48
+ }
49
+
50
+ describe("TagSection", () => {
51
+ test("shows assigned + available tags and dispatches assign/remove with the right QN + payload", async () => {
52
+ catalogRows = [
53
+ { id: "t1", name: "important" },
54
+ { id: "t2", name: "project-x" },
55
+ ];
56
+ assignmentRows = [{ tagId: "t1", entityType: "note", entityId: "note-1" }];
57
+ dispatchSpy.mockClear();
58
+
59
+ render(
60
+ <Wrapper>
61
+ <TagSection entityName="note" entityId="note-1" />
62
+ </Wrapper>,
63
+ );
64
+
65
+ // Assigned tag → remove button; unassigned catalog tag → assign button.
66
+ expect(screen.getByTestId("tags-section-remove-t1")).toBeTruthy();
67
+ expect(screen.getByTestId("tags-section-assign-t2")).toBeTruthy();
68
+ expect(screen.queryByTestId("tags-section-assign-t1")).toBeNull();
69
+
70
+ fireEvent.click(screen.getByTestId("tags-section-assign-t2"));
71
+ await waitFor(() =>
72
+ expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
73
+ tagId: "t2",
74
+ entityType: "note",
75
+ entityId: "note-1",
76
+ }),
77
+ );
78
+
79
+ fireEvent.click(screen.getByTestId("tags-section-remove-t1"));
80
+ await waitFor(() =>
81
+ expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.removeTag, {
82
+ tagId: "t1",
83
+ entityType: "note",
84
+ entityId: "note-1",
85
+ }),
86
+ );
87
+ });
88
+
89
+ test("create-and-attach dispatches create-tag, then assign-tag with the new id", async () => {
90
+ catalogRows = [];
91
+ assignmentRows = [];
92
+ dispatchSpy.mockClear();
93
+
94
+ render(
95
+ <Wrapper>
96
+ <TagSection entityName="note" entityId="note-9" />
97
+ </Wrapper>,
98
+ );
99
+
100
+ fireEvent.change(document.getElementById("tags-section-new") as HTMLInputElement, {
101
+ target: { value: "urgent" },
102
+ });
103
+ fireEvent.click(screen.getByTestId("tags-section-create"));
104
+
105
+ await waitFor(() =>
106
+ expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.createTag, { name: "urgent" }),
107
+ );
108
+ await waitFor(() =>
109
+ expect(dispatchSpy).toHaveBeenCalledWith(TagsHandlers.assignTag, {
110
+ tagId: "tag-new",
111
+ entityType: "note",
112
+ entityId: "note-9",
113
+ }),
114
+ );
115
+ });
116
+
117
+ test("create-mode (no entityId yet) shows the save-first hint instead of the manager", () => {
118
+ render(
119
+ <Wrapper>
120
+ <TagSection entityName="note" entityId={null} />
121
+ </Wrapper>,
122
+ );
123
+ expect(screen.getByTestId("tags-section-create-mode")).toBeTruthy();
124
+ expect(screen.queryByTestId("tags-section")).toBeNull();
125
+ });
126
+ });
@@ -0,0 +1,21 @@
1
+ // @runtime client
2
+ // Client-feature factory for tags. Mounted via
3
+ // createKumikoApp({ clientFeatures: [tagsClient()] }) — registers TagSection
4
+ // under TAGS_SECTION_EXTENSION_NAME and contributes the default translations.
5
+ // Required even for standalone <TagSection> use, otherwise its i18n keys render
6
+ // raw.
7
+
8
+ import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer-web";
9
+ import { TAGS_FEATURE_NAME, TAGS_SECTION_EXTENSION_NAME } from "../constants";
10
+ import { defaultTranslations } from "./i18n";
11
+ import { TagSection } from "./tag-section";
12
+
13
+ export function tagsClient(): ClientFeatureDefinition {
14
+ return {
15
+ name: TAGS_FEATURE_NAME,
16
+ extensionSectionComponents: {
17
+ [TAGS_SECTION_EXTENSION_NAME]: TagSection,
18
+ },
19
+ translations: defaultTranslations,
20
+ };
21
+ }
@@ -0,0 +1,25 @@
1
+ // @runtime client
2
+ // Default translation bundle for the tags UI. tagsClient() hangs it into the
3
+ // LocaleProvider as a fallback bundle — apps override individual keys via
4
+ // tagsClient({ translations: { de: { ... } } }). Keys follow `tags.<area>.<slug>`.
5
+
6
+ import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
7
+
8
+ export const defaultTranslations: TranslationsByLocale = {
9
+ de: {
10
+ "tags.section.createMode": "Speichere zuerst den Eintrag, um Tags zu setzen.",
11
+ "tags.section.loading": "Lädt…",
12
+ "tags.section.none": "Keine Tags.",
13
+ "tags.section.newLabel": "Neuer Tag",
14
+ "tags.section.create": "Tag anlegen & zuweisen",
15
+ "tags.section.working": "Speichert…",
16
+ },
17
+ en: {
18
+ "tags.section.createMode": "Save the entity first to add tags.",
19
+ "tags.section.loading": "Loading…",
20
+ "tags.section.none": "No tags.",
21
+ "tags.section.newLabel": "New tag",
22
+ "tags.section.create": "Create & attach tag",
23
+ "tags.section.working": "Saving…",
24
+ },
25
+ };
@@ -0,0 +1,4 @@
1
+ // @runtime client
2
+ export { TAGS_SECTION_EXTENSION_NAME, TagsHandlers, TagsQueries } from "../constants";
3
+ export { tagsClient } from "./client-plugin";
4
+ export { TagSection } from "./tag-section";
@@ -0,0 +1,204 @@
1
+ // @runtime client
2
+ // TagSection — drop-in tag manager for ANY entity. Given an entityName +
3
+ // entityId it shows the entity's current tags and lets the user attach an
4
+ // existing tag, create-and-attach a new one, or detach a tag. Tag writes are
5
+ // immediate (assign/remove are idempotent), so the section owns its own state
6
+ // and refetches after each action — it is NOT part of a host form's save.
7
+ //
8
+ // Two ways to mount (both need tagsClient() registered once, for i18n):
9
+ // - standalone: <TagSection entityName="note" entityId={noteId} />
10
+ // - extension: a screen-schema section with
11
+ // component: { react: { __component: TAGS_SECTION_EXTENSION_NAME } }
12
+ // (RenderEdit passes { entityName, entityId }).
13
+
14
+ import {
15
+ useDispatcher,
16
+ usePrimitives,
17
+ useQuery,
18
+ useTranslation,
19
+ } from "@cosmicdrift/kumiko-renderer";
20
+ import { type ReactNode, useState } from "react";
21
+ import { TagsHandlers, TagsQueries } from "../constants";
22
+
23
+ type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
24
+ type AssignmentRow = {
25
+ readonly tagId: string;
26
+ readonly entityType: string;
27
+ readonly entityId: string;
28
+ };
29
+ type TagListResponse = { readonly rows: readonly TagRow[] };
30
+ type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
31
+
32
+ // Structural shape of a dispatcher write result for the generic action wrapper.
33
+ // The real WriteResult (a discriminated union) is assignable to this; narrowing
34
+ // on `isSuccess` reaches `error.i18nKey` without importing server-side types.
35
+ type ActionResult =
36
+ | { readonly isSuccess: true }
37
+ | { readonly isSuccess: false; readonly error: { readonly i18nKey: string } };
38
+
39
+ export function TagSection({
40
+ entityName,
41
+ entityId,
42
+ }: {
43
+ readonly entityName: string;
44
+ readonly entityId: string | null;
45
+ }): ReactNode {
46
+ const { Banner, Button, Field, Input, Text } = usePrimitives();
47
+ const t = useTranslation();
48
+ const dispatcher = useDispatcher();
49
+ const enabled = entityId !== null;
50
+ const catalog = useQuery<TagListResponse>(TagsQueries.tagList, {}, { enabled });
51
+ const assignments = useQuery<AssignmentListResponse>(
52
+ TagsQueries.assignmentList,
53
+ { filter: { field: "entityId", op: "eq", value: entityId } },
54
+ { enabled },
55
+ );
56
+ const [newName, setNewName] = useState("");
57
+ const [busy, setBusy] = useState(false);
58
+ const [errorKey, setErrorKey] = useState<string | null>(null);
59
+
60
+ if (entityId === null) {
61
+ return (
62
+ <Banner variant="info" testId="tags-section-create-mode">
63
+ <Text>{t("tags.section.createMode")}</Text>
64
+ </Banner>
65
+ );
66
+ }
67
+ if (
68
+ (catalog.loading && catalog.data === null) ||
69
+ (assignments.loading && assignments.data === null)
70
+ ) {
71
+ return (
72
+ <Banner variant="loading" testId="tags-section-loading">
73
+ <Text>{t("tags.section.loading")}</Text>
74
+ </Banner>
75
+ );
76
+ }
77
+ const queryError = catalog.error ?? assignments.error;
78
+ if (queryError) {
79
+ return (
80
+ <Banner variant="error" testId="tags-section-error">
81
+ <Text>{t(queryError.i18nKey, queryError.i18nParams)}</Text>
82
+ </Banner>
83
+ );
84
+ }
85
+
86
+ const catalogTags = catalog.data?.rows ?? [];
87
+ const byId = new Map(catalogTags.map((tg) => [tg.id, tg]));
88
+ const assignedRows = (assignments.data?.rows ?? []).filter((r) => r.entityType === entityName);
89
+ const assignedIds = new Set(assignedRows.map((r) => r.tagId));
90
+ const assignedTags = assignedRows.map((r) => byId.get(r.tagId) ?? { id: r.tagId, name: r.tagId });
91
+ const available = catalogTags.filter((tg) => !assignedIds.has(tg.id));
92
+
93
+ const refetch = async (): Promise<void> => {
94
+ await Promise.all([catalog.refetch(), assignments.refetch()]);
95
+ };
96
+
97
+ const run = async (action: () => Promise<ActionResult>): Promise<void> => {
98
+ setBusy(true);
99
+ setErrorKey(null);
100
+ try {
101
+ const result = await action();
102
+ if (!result.isSuccess) {
103
+ setErrorKey(result.error.i18nKey);
104
+ return;
105
+ }
106
+ await refetch();
107
+ } finally {
108
+ setBusy(false);
109
+ }
110
+ };
111
+
112
+ const assign = (tagId: string): Promise<void> =>
113
+ run(() =>
114
+ dispatcher.write(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId }),
115
+ );
116
+
117
+ const detach = (tagId: string): Promise<void> =>
118
+ run(() =>
119
+ dispatcher.write(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId }),
120
+ );
121
+
122
+ const createAndAssign = async (): Promise<void> => {
123
+ const name = newName.trim();
124
+ if (name === "") return;
125
+ setBusy(true);
126
+ setErrorKey(null);
127
+ try {
128
+ const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, { name });
129
+ if (!created.isSuccess) {
130
+ setErrorKey(created.error.i18nKey);
131
+ return;
132
+ }
133
+ const assigned = await dispatcher.write(TagsHandlers.assignTag, {
134
+ tagId: created.data.id,
135
+ entityType: entityName,
136
+ entityId,
137
+ });
138
+ if (!assigned.isSuccess) {
139
+ setErrorKey(assigned.error.i18nKey);
140
+ return;
141
+ }
142
+ setNewName("");
143
+ await refetch();
144
+ } finally {
145
+ setBusy(false);
146
+ }
147
+ };
148
+
149
+ return (
150
+ <div data-testid="tags-section">
151
+ {assignedTags.length === 0 ? (
152
+ <Text>{t("tags.section.none")}</Text>
153
+ ) : (
154
+ assignedTags.map((tg) => (
155
+ <Button
156
+ key={tg.id}
157
+ variant="secondary"
158
+ disabled={busy}
159
+ onClick={() => void detach(tg.id)}
160
+ testId={`tags-section-remove-${tg.id}`}
161
+ >
162
+ {`${tg.name} ✕`}
163
+ </Button>
164
+ ))
165
+ )}
166
+
167
+ {available.map((tg) => (
168
+ <Button
169
+ key={tg.id}
170
+ variant="secondary"
171
+ disabled={busy}
172
+ onClick={() => void assign(tg.id)}
173
+ testId={`tags-section-assign-${tg.id}`}
174
+ >
175
+ {`+ ${tg.name}`}
176
+ </Button>
177
+ ))}
178
+
179
+ <Field id="tags-section-new" label={t("tags.section.newLabel")}>
180
+ <Input
181
+ kind="text"
182
+ id="tags-section-new"
183
+ name="newTag"
184
+ value={newName}
185
+ onChange={setNewName}
186
+ />
187
+ </Field>
188
+ <Button
189
+ variant="primary"
190
+ disabled={busy || newName.trim() === ""}
191
+ onClick={() => void createAndAssign()}
192
+ testId="tags-section-create"
193
+ >
194
+ {busy ? t("tags.section.working") : t("tags.section.create")}
195
+ </Button>
196
+
197
+ {errorKey !== null && (
198
+ <Banner variant="error" testId="tags-section-action-error">
199
+ <Text>{t(errorKey)}</Text>
200
+ </Banner>
201
+ )}
202
+ </div>
203
+ );
204
+ }