@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.
- package/package.json +6 -6
- package/src/tags/__tests__/feature.test.ts +46 -13
- package/src/tags/__tests__/tags.integration.test.ts +66 -4
- package/src/tags/constants.ts +11 -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 +14 -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-section.test.tsx +51 -77
- package/src/tags/web/client-plugin.tsx +14 -1
- package/src/tags/web/entity-tags.tsx +47 -0
- package/src/tags/web/i18n.ts +39 -8
- package/src/tags/web/index.ts +12 -1
- package/src/tags/web/tag-chip.tsx +63 -0
- package/src/tags/web/tag-filter.tsx +88 -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/handlers/rename-tag.write.ts +0 -26
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.98.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>",
|
|
@@ -91,11 +91,11 @@
|
|
|
91
91
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
92
92
|
},
|
|
93
93
|
"dependencies": {
|
|
94
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
95
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
94
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.98.0",
|
|
95
|
+
"@cosmicdrift/kumiko-framework": "0.98.0",
|
|
96
|
+
"@cosmicdrift/kumiko-headless": "0.98.0",
|
|
97
|
+
"@cosmicdrift/kumiko-renderer": "0.98.0",
|
|
98
|
+
"@cosmicdrift/kumiko-renderer-web": "0.98.0",
|
|
99
99
|
"@mollie/api-client": "^4.5.0",
|
|
100
100
|
"@node-rs/argon2": "^2.0.2",
|
|
101
101
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -4,8 +4,9 @@ import { createTagsFeature } from "../feature";
|
|
|
4
4
|
import {
|
|
5
5
|
assignTagPayloadSchema,
|
|
6
6
|
createTagPayloadSchema,
|
|
7
|
+
deleteTagPayloadSchema,
|
|
7
8
|
removeTagPayloadSchema,
|
|
8
|
-
|
|
9
|
+
updateTagPayloadSchema,
|
|
9
10
|
} from "../schemas";
|
|
10
11
|
|
|
11
12
|
// Unit tests: feature-shape, role-options, schema-validation. The ES-loop
|
|
@@ -47,7 +48,7 @@ function rawQueryAccess(feature: ReturnType<typeof createTagsFeature>, nameMatch
|
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
describe("createTagsFeature shape", () => {
|
|
50
|
-
test("registers tag + tag-assignment entities,
|
|
51
|
+
test("registers tag + tag-assignment entities, 5 write-handlers, 2 query-handlers", () => {
|
|
51
52
|
const feature = createTagsFeature();
|
|
52
53
|
|
|
53
54
|
expect(Object.keys(feature.entities ?? {})).toEqual(
|
|
@@ -57,12 +58,13 @@ describe("createTagsFeature shape", () => {
|
|
|
57
58
|
expect(Object.keys(feature.writeHandlers)).toEqual(
|
|
58
59
|
expect.arrayContaining([
|
|
59
60
|
expect.stringMatching(/create-tag/),
|
|
60
|
-
expect.stringMatching(/
|
|
61
|
+
expect.stringMatching(/update-tag/),
|
|
62
|
+
expect.stringMatching(/delete-tag/),
|
|
61
63
|
expect.stringMatching(/assign-tag/),
|
|
62
64
|
expect.stringMatching(/remove-tag/),
|
|
63
65
|
]),
|
|
64
66
|
);
|
|
65
|
-
expect(Object.keys(feature.writeHandlers)).toHaveLength(
|
|
67
|
+
expect(Object.keys(feature.writeHandlers)).toHaveLength(5);
|
|
66
68
|
|
|
67
69
|
expect(Object.keys(feature.queryHandlers)).toEqual(
|
|
68
70
|
expect.arrayContaining([
|
|
@@ -79,7 +81,8 @@ describe("createTagsFeature access-options", () => {
|
|
|
79
81
|
const feature = createTagsFeature();
|
|
80
82
|
expect(feature).toBe(createTagsFeature());
|
|
81
83
|
expect(writeAccess(feature, "create-tag")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
82
|
-
expect(writeAccess(feature, "
|
|
84
|
+
expect(writeAccess(feature, "update-tag")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
85
|
+
expect(writeAccess(feature, "delete-tag")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
83
86
|
expect(writeAccess(feature, "assign-tag")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
84
87
|
expect(writeAccess(feature, "remove-tag")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
85
88
|
expect(queryAccess(feature, "tag:list")).toEqual([...DEFAULT_TAG_ROLES]);
|
|
@@ -89,7 +92,8 @@ describe("createTagsFeature access-options", () => {
|
|
|
89
92
|
test("roles option overrides every write- and query-path", () => {
|
|
90
93
|
const feature = createTagsFeature({ roles: ["Admin", "Editor"] });
|
|
91
94
|
expect(writeAccess(feature, "create-tag")).toEqual(["Admin", "Editor"]);
|
|
92
|
-
expect(writeAccess(feature, "
|
|
95
|
+
expect(writeAccess(feature, "update-tag")).toEqual(["Admin", "Editor"]);
|
|
96
|
+
expect(writeAccess(feature, "delete-tag")).toEqual(["Admin", "Editor"]);
|
|
93
97
|
expect(writeAccess(feature, "assign-tag")).toEqual(["Admin", "Editor"]);
|
|
94
98
|
expect(writeAccess(feature, "remove-tag")).toEqual(["Admin", "Editor"]);
|
|
95
99
|
expect(queryAccess(feature, "tag:list")).toEqual(["Admin", "Editor"]);
|
|
@@ -98,7 +102,7 @@ describe("createTagsFeature access-options", () => {
|
|
|
98
102
|
|
|
99
103
|
test("access:{openToAll} applies to every write- and query-path", () => {
|
|
100
104
|
const feature = createTagsFeature({ access: { openToAll: true } });
|
|
101
|
-
for (const path of ["create-tag", "
|
|
105
|
+
for (const path of ["create-tag", "update-tag", "delete-tag", "assign-tag", "remove-tag"]) {
|
|
102
106
|
expect(rawWriteAccess(feature, path)).toEqual({ openToAll: true });
|
|
103
107
|
}
|
|
104
108
|
for (const query of ["tag:list", "tag-assignment:list"]) {
|
|
@@ -163,28 +167,57 @@ describe("createTagPayloadSchema", () => {
|
|
|
163
167
|
});
|
|
164
168
|
});
|
|
165
169
|
|
|
166
|
-
describe("
|
|
170
|
+
describe("updateTagPayloadSchema", () => {
|
|
167
171
|
const valid = { id: "tag-1", version: 0, name: "Neu" };
|
|
168
172
|
|
|
169
|
-
test("accepts id + version + name", () => {
|
|
170
|
-
expect(
|
|
173
|
+
test("accepts id + version + name (rename)", () => {
|
|
174
|
+
expect(updateTagPayloadSchema.safeParse(valid).success).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("accepts color-only (recolor) and scope-only (re-scope)", () => {
|
|
178
|
+
expect(updateTagPayloadSchema.safeParse({ id: "t", version: 0, color: "#abc" }).success).toBe(
|
|
179
|
+
true,
|
|
180
|
+
);
|
|
181
|
+
expect(updateTagPayloadSchema.safeParse({ id: "t", version: 0, scope: "note" }).success).toBe(
|
|
182
|
+
true,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("accepts color/scope = '' to clear them", () => {
|
|
187
|
+
expect(
|
|
188
|
+
updateTagPayloadSchema.safeParse({ id: "t", version: 0, color: "", scope: "" }).success,
|
|
189
|
+
).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("rejects a no-op (id + version, no mutable field)", () => {
|
|
193
|
+
expect(updateTagPayloadSchema.safeParse({ id: "tag-1", version: 0 }).success).toBe(false);
|
|
171
194
|
});
|
|
172
195
|
|
|
173
196
|
test("rejects a missing version (optimistic lock is mandatory)", () => {
|
|
174
|
-
expect(
|
|
197
|
+
expect(updateTagPayloadSchema.safeParse({ id: "tag-1", name: "Neu" }).success).toBe(false);
|
|
175
198
|
});
|
|
176
199
|
|
|
177
200
|
test("rejects an empty name", () => {
|
|
178
|
-
expect(
|
|
201
|
+
expect(updateTagPayloadSchema.safeParse({ ...valid, name: "" }).success).toBe(false);
|
|
179
202
|
});
|
|
180
203
|
|
|
181
204
|
test("rejects a name over 64 chars", () => {
|
|
182
|
-
expect(
|
|
205
|
+
expect(updateTagPayloadSchema.safeParse({ ...valid, name: "x".repeat(65) }).success).toBe(
|
|
183
206
|
false,
|
|
184
207
|
);
|
|
185
208
|
});
|
|
186
209
|
});
|
|
187
210
|
|
|
211
|
+
describe("deleteTagPayloadSchema", () => {
|
|
212
|
+
test("accepts a bare id (no version — destructive intent)", () => {
|
|
213
|
+
expect(deleteTagPayloadSchema.safeParse({ id: "tag-1" }).success).toBe(true);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("rejects an empty id", () => {
|
|
217
|
+
expect(deleteTagPayloadSchema.safeParse({ id: "" }).success).toBe(false);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
188
221
|
describe("assign/remove payload schemas", () => {
|
|
189
222
|
const valid = { tagId: "tag-1", entityType: "credit", entityId: "c-1" };
|
|
190
223
|
|
|
@@ -61,6 +61,10 @@ async function remove(tagId: string, entityType: string, entityId: string, user
|
|
|
61
61
|
return stack.http.writeOk(TagsHandlers.removeTag, { tagId, entityType, entityId }, user);
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
async function deleteTag(id: string, user = admin) {
|
|
65
|
+
return stack.http.writeOk(TagsHandlers.deleteTag, { id }, user);
|
|
66
|
+
}
|
|
67
|
+
|
|
64
68
|
async function tagById(id: string, user = admin): Promise<Record<string, unknown> | undefined> {
|
|
65
69
|
return (await listTags(user)).find((t) => t["id"] === id);
|
|
66
70
|
}
|
|
@@ -147,7 +151,7 @@ describe("tags integration — rename", () => {
|
|
|
147
151
|
expect(typeof before?.["version"]).toBe("number");
|
|
148
152
|
const version = before?.["version"] as number;
|
|
149
153
|
|
|
150
|
-
await stack.http.writeOk(TagsHandlers.
|
|
154
|
+
await stack.http.writeOk(TagsHandlers.updateTag, { id, version, name: "Mandant Neu" }, admin);
|
|
151
155
|
|
|
152
156
|
const after = await tagById(id);
|
|
153
157
|
expect(after?.["name"]).toBe("Mandant Neu");
|
|
@@ -162,10 +166,10 @@ describe("tags integration — rename", () => {
|
|
|
162
166
|
admin,
|
|
163
167
|
);
|
|
164
168
|
const stale = (await tagById(id))?.["version"] as number;
|
|
165
|
-
await stack.http.writeOk(TagsHandlers.
|
|
169
|
+
await stack.http.writeOk(TagsHandlers.updateTag, { id, version: stale, name: "Erster" }, admin);
|
|
166
170
|
|
|
167
171
|
const err = await stack.http.writeErr(
|
|
168
|
-
TagsHandlers.
|
|
172
|
+
TagsHandlers.updateTag,
|
|
169
173
|
{ id, version: stale, name: "Zweiter" }, // stale: the row already moved to stale+1
|
|
170
174
|
admin,
|
|
171
175
|
);
|
|
@@ -182,7 +186,7 @@ describe("tags integration — rename", () => {
|
|
|
182
186
|
const version = (await tagById(id, admin))?.["version"] as number;
|
|
183
187
|
|
|
184
188
|
const err = await stack.http.writeErr(
|
|
185
|
-
TagsHandlers.
|
|
189
|
+
TagsHandlers.updateTag,
|
|
186
190
|
{ id, version, name: "B-Übernahme" },
|
|
187
191
|
otherTenant,
|
|
188
192
|
);
|
|
@@ -191,6 +195,64 @@ describe("tags integration — rename", () => {
|
|
|
191
195
|
});
|
|
192
196
|
});
|
|
193
197
|
|
|
198
|
+
describe("tags integration — update (recolor / re-scope)", () => {
|
|
199
|
+
test("update-tag changes color + scope, preserves the untouched name", async () => {
|
|
200
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
201
|
+
TagsHandlers.createTag,
|
|
202
|
+
{ name: "Projekt", color: "#111111" },
|
|
203
|
+
admin,
|
|
204
|
+
);
|
|
205
|
+
const version = (await tagById(id))?.["version"] as number;
|
|
206
|
+
await stack.http.writeOk(
|
|
207
|
+
TagsHandlers.updateTag,
|
|
208
|
+
{ id, version, color: "#22cc88", scope: "note" },
|
|
209
|
+
admin,
|
|
210
|
+
);
|
|
211
|
+
const after = await tagById(id);
|
|
212
|
+
expect(after?.["name"]).toBe("Projekt");
|
|
213
|
+
expect(after?.["color"]).toBe("#22cc88");
|
|
214
|
+
expect(after?.["scope"]).toBe("note");
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("update-tag with empty color clears it", async () => {
|
|
218
|
+
const { id } = await stack.http.writeOk<{ id: string }>(
|
|
219
|
+
TagsHandlers.createTag,
|
|
220
|
+
{ name: "Farbe", color: "#abcdef" },
|
|
221
|
+
admin,
|
|
222
|
+
);
|
|
223
|
+
const version = (await tagById(id))?.["version"] as number;
|
|
224
|
+
await stack.http.writeOk(TagsHandlers.updateTag, { id, version, color: "" }, admin);
|
|
225
|
+
expect((await tagById(id))?.["color"]).toBe("");
|
|
226
|
+
});
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
describe("tags integration — delete-tag cascade", () => {
|
|
230
|
+
test("deletes the tag and detaches it from every entity (multiple types)", async () => {
|
|
231
|
+
const target = await createTag("Sammelmappe");
|
|
232
|
+
const other = await createTag("bleibt");
|
|
233
|
+
// target spans two entityTypes; other tag stays attached to prove scoping
|
|
234
|
+
await assign(target, "credit", "credit-d1");
|
|
235
|
+
await assign(target, "note", "note-d1");
|
|
236
|
+
await assign(other, "credit", "credit-d1");
|
|
237
|
+
expect(await countAssignments(admin.tenantId)).toBe(3);
|
|
238
|
+
|
|
239
|
+
await deleteTag(target);
|
|
240
|
+
|
|
241
|
+
expect(await tagById(target)).toBeUndefined();
|
|
242
|
+
expect(await tagById(other)).toBeDefined();
|
|
243
|
+
expect(await countAssignments(admin.tenantId)).toBe(1);
|
|
244
|
+
expect(await listAssignments({ field: "tagId", op: "eq", value: target })).toHaveLength(0);
|
|
245
|
+
expect(await listAssignments({ field: "tagId", op: "eq", value: other })).toHaveLength(1);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("deleting an already-gone tag is idempotent success", async () => {
|
|
249
|
+
const id = await createTag("einmalig");
|
|
250
|
+
await deleteTag(id);
|
|
251
|
+
await deleteTag(id); // second call must not error
|
|
252
|
+
expect(await tagById(id)).toBeUndefined();
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
194
256
|
describe("tags integration — many-to-many composition", () => {
|
|
195
257
|
test("one entity carries multiple tags", async () => {
|
|
196
258
|
const a = await createTag("rot");
|
package/src/tags/constants.ts
CHANGED
|
@@ -14,11 +14,21 @@ export const TAGS_FEATURE_NAME = "tags";
|
|
|
14
14
|
// standalone use from `@cosmicdrift/kumiko-bundled-features/tags/web`.
|
|
15
15
|
export const TAGS_SECTION_EXTENSION_NAME = "TagSection";
|
|
16
16
|
|
|
17
|
+
// Registry name for the <TagFilter> header-slot control. A host entityList wires
|
|
18
|
+
// it via `slots: { header: { react: { __component: TAGS_FILTER_EXTENSION_NAME } } }`
|
|
19
|
+
// after mounting tagsClient(); the renderer passes it the list's screenId.
|
|
20
|
+
export const TAGS_FILTER_EXTENSION_NAME = "TagFilter";
|
|
21
|
+
|
|
22
|
+
// Screen-id of the standalone Tags management screen (custom screen rendering
|
|
23
|
+
// TagManager). Qualified = "tags:screen:tag-list"; the app places it via r.nav.
|
|
24
|
+
export const TAGS_SCREEN_ID = "tag-list";
|
|
25
|
+
|
|
17
26
|
// Qualified handler names (QN format: scope:type:name). Clients reference the
|
|
18
27
|
// object instead of magic strings (mirror custom-fields' Handlers/Queries).
|
|
19
28
|
export const TagsHandlers = {
|
|
20
29
|
createTag: "tags:write:create-tag",
|
|
21
|
-
|
|
30
|
+
updateTag: "tags:write:update-tag",
|
|
31
|
+
deleteTag: "tags:write:delete-tag",
|
|
22
32
|
assignTag: "tags:write:assign-tag",
|
|
23
33
|
removeTag: "tags:write:remove-tag",
|
|
24
34
|
} as const;
|
package/src/tags/entity.ts
CHANGED
|
@@ -9,6 +9,10 @@ export const tagEntity = createEntity({
|
|
|
9
9
|
name: createTextField({ required: true, maxLength: 64 }),
|
|
10
10
|
// Optional UI hint (hex or token). No enforcement — purely for rendering.
|
|
11
11
|
color: createTextField({ maxLength: 32 }),
|
|
12
|
+
// Optional entity-type scope (GitLab project-vs-group labels): empty = global
|
|
13
|
+
// (offered on every entity); a value like "note" restricts the tag to that
|
|
14
|
+
// entityType in the picker. No enforcement on assign — purely a picker hint.
|
|
15
|
+
scope: createTextField({ maxLength: 64 }),
|
|
12
16
|
},
|
|
13
17
|
});
|
|
14
18
|
|
package/src/tags/feature.ts
CHANGED
|
@@ -12,9 +12,9 @@
|
|
|
12
12
|
// tag-assignments filtered on entityId (tags of an entity) or tagId (entities
|
|
13
13
|
// with a tag). See entity.ts.
|
|
14
14
|
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
// (`wireTagsFor`), search indexing
|
|
15
|
+
// Handlers: create-tag, update-tag (rename/recolor/re-scope), delete-tag
|
|
16
|
+
// (cascades over assignments), assign-tag, remove-tag, list tags, list assignments.
|
|
17
|
+
// Deferred: optional host-projection decoration (`wireTagsFor`), search indexing.
|
|
18
18
|
|
|
19
19
|
import {
|
|
20
20
|
type AccessRule,
|
|
@@ -22,12 +22,13 @@ import {
|
|
|
22
22
|
defineFeature,
|
|
23
23
|
type FeatureRegistrar,
|
|
24
24
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
25
|
-
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME } from "./constants";
|
|
25
|
+
import { DEFAULT_TAG_ACCESS, TAGS_FEATURE_NAME, TAGS_SCREEN_ID } from "./constants";
|
|
26
26
|
import { tagAssignmentEntity, tagEntity } from "./entity";
|
|
27
27
|
import { createAssignTagHandler } from "./handlers/assign-tag.write";
|
|
28
28
|
import { createCreateTagHandler } from "./handlers/create-tag.write";
|
|
29
|
+
import { createDeleteTagHandler } from "./handlers/delete-tag.write";
|
|
29
30
|
import { createRemoveTagHandler } from "./handlers/remove-tag.write";
|
|
30
|
-
import {
|
|
31
|
+
import { createUpdateTagHandler } from "./handlers/update-tag.write";
|
|
31
32
|
|
|
32
33
|
// Opt-in tier-gating: when set, the feature declares itself r.toggleable so the
|
|
33
34
|
// dispatcher gate + feature-toggles + tier-engine can switch the WHOLE feature
|
|
@@ -43,7 +44,7 @@ function registerTags(
|
|
|
43
44
|
toggleable: TagsToggleable | undefined,
|
|
44
45
|
): void {
|
|
45
46
|
r.describe(
|
|
46
|
-
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `
|
|
47
|
+
"Generic, host-agnostic tagging for any entity. Owns two event-sourced entities — the per-tenant `tag` catalog (`read_tags`, with optional `color` and `scope`) and `tag-assignment` join rows keyed by (entityType, entityId) (`read_tag_assignments`) — so tagging adds NO column to the host entity and needs no relational pivot or JOIN. Provides write-handlers `create-tag`, `update-tag` (optimistic-locked rename/recolor/re-scope), `delete-tag` (cascades over assignments), `assign-tag` (idempotent), `remove-tag` (idempotent) and list queries for the catalog and the assignments. Read which tags an entity has, or which entities carry a tag, by listing `tag-assignment` filtered on `entityId` or `tagId` and composing in the read-layer. A tag with empty `scope` is global; a `scope` of an entityType restricts it to that type in the picker. Every path uses one access rule — adopt the host's model with createTagsFeature({ access: { openToAll: true } }) or pin roles with createTagsFeature({ roles }). Pass { toggleable: { default: false } } to make the whole feature tier-gatable via the tier-engine (no host hook).",
|
|
47
48
|
);
|
|
48
49
|
r.uiHints({
|
|
49
50
|
displayLabel: "Tags",
|
|
@@ -59,12 +60,22 @@ function registerTags(
|
|
|
59
60
|
r.entity("tag-assignment", tagAssignmentEntity);
|
|
60
61
|
|
|
61
62
|
r.writeHandler(createCreateTagHandler(access));
|
|
62
|
-
r.writeHandler(
|
|
63
|
+
r.writeHandler(createUpdateTagHandler(access));
|
|
64
|
+
r.writeHandler(createDeleteTagHandler(access));
|
|
63
65
|
r.writeHandler(createAssignTagHandler(access));
|
|
64
66
|
r.writeHandler(createRemoveTagHandler(access));
|
|
65
67
|
|
|
66
68
|
r.queryHandler(defineEntityListHandler("tag", tagEntity, { access }));
|
|
67
69
|
r.queryHandler(defineEntityListHandler("tag-assignment", tagAssignmentEntity, { access }));
|
|
70
|
+
|
|
71
|
+
// Standalone Tags management screen (custom React: TagManager). The app places
|
|
72
|
+
// it in nav via r.nav("tags:screen:tag-list"); tagsClient() maps the component.
|
|
73
|
+
r.screen({
|
|
74
|
+
id: TAGS_SCREEN_ID,
|
|
75
|
+
type: "custom",
|
|
76
|
+
renderer: { react: { __component: "TagsScreen" } },
|
|
77
|
+
access,
|
|
78
|
+
});
|
|
68
79
|
}
|
|
69
80
|
|
|
70
81
|
export const tagsFeature = defineFeature(TAGS_FEATURE_NAME, (r) =>
|
|
@@ -6,7 +6,7 @@ import { type CreateTagPayload, createTagPayloadSchema } from "../schemas";
|
|
|
6
6
|
// create-tag — adds a tag to the tenant's catalog. The framework mints a fresh
|
|
7
7
|
// UUIDv7 id (no explicit id passed). Tag names are not unique by design: the
|
|
8
8
|
// catalog is a free list and dedup is a UI concern (autocomplete from existing
|
|
9
|
-
// tags).
|
|
9
|
+
// tags). Editing is update-tag.write.ts; delete (with cascade) is delete-tag.write.ts.
|
|
10
10
|
export function createCreateTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
|
|
11
11
|
return {
|
|
12
12
|
name: "create-tag",
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { AccessRule, WriteHandlerDef } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { DEFAULT_TAG_ACCESS } from "../constants";
|
|
3
|
+
import { tagAssignmentExecutor, tagExecutor } from "../executor";
|
|
4
|
+
import { type DeleteTagPayload, deleteTagPayloadSchema } from "../schemas";
|
|
5
|
+
|
|
6
|
+
const CASCADE_PAGE = 200;
|
|
7
|
+
|
|
8
|
+
// delete-tag — removes a tag from the catalog and detaches it everywhere. No FK
|
|
9
|
+
// (event-sourced, no JOIN), so the handler cascades: soft-delete every
|
|
10
|
+
// assignment carrying this tag, then hard-delete the catalog tag.
|
|
11
|
+
//
|
|
12
|
+
// Idempotent: deleting an already-gone tag returns success (mirrors remove-tag).
|
|
13
|
+
//
|
|
14
|
+
// The cascade re-reads page 1 (no cursor) until it comes back empty instead of
|
|
15
|
+
// paging with a cursor: assignment deletes are soft-deletes and the list query
|
|
16
|
+
// hides isDeleted rows, so a keyset cursor over the shrinking result set would
|
|
17
|
+
// silently skip rows. Re-reading the head always returns the remaining live
|
|
18
|
+
// rows and terminates when none are left.
|
|
19
|
+
export function createDeleteTagHandler(access: AccessRule = DEFAULT_TAG_ACCESS): WriteHandlerDef {
|
|
20
|
+
return {
|
|
21
|
+
name: "delete-tag",
|
|
22
|
+
schema: deleteTagPayloadSchema,
|
|
23
|
+
access,
|
|
24
|
+
handler: async (event, ctx) => {
|
|
25
|
+
const payload = event.payload as DeleteTagPayload; // @cast-boundary engine-payload
|
|
26
|
+
|
|
27
|
+
const existing = await tagExecutor.detail({ id: payload.id }, event.user, ctx.db);
|
|
28
|
+
if (!existing) {
|
|
29
|
+
return { isSuccess: true as const, data: { id: payload.id } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (;;) {
|
|
33
|
+
const page = await tagAssignmentExecutor.list(
|
|
34
|
+
{ filter: { field: "tagId", op: "eq", value: payload.id }, limit: CASCADE_PAGE },
|
|
35
|
+
event.user,
|
|
36
|
+
ctx.db,
|
|
37
|
+
);
|
|
38
|
+
if (page.rows.length === 0) break;
|
|
39
|
+
for (const row of page.rows) {
|
|
40
|
+
const removed = await tagAssignmentExecutor.delete(
|
|
41
|
+
{ id: String(row["id"]) },
|
|
42
|
+
event.user,
|
|
43
|
+
ctx.db,
|
|
44
|
+
);
|
|
45
|
+
if (!removed.isSuccess) return removed;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return tagExecutor.delete({ id: payload.id }, event.user, ctx.db);
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const deleteTagHandler: WriteHandlerDef = createDeleteTagHandler();
|
|
@@ -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
|
@@ -3,6 +3,9 @@ export {
|
|
|
3
3
|
DEFAULT_TAG_ACCESS,
|
|
4
4
|
DEFAULT_TAG_ROLES,
|
|
5
5
|
TAGS_FEATURE_NAME,
|
|
6
|
+
TAGS_FILTER_EXTENSION_NAME,
|
|
7
|
+
TAGS_SCREEN_ID,
|
|
8
|
+
TAGS_SECTION_EXTENSION_NAME,
|
|
6
9
|
TagsHandlers,
|
|
7
10
|
TagsQueries,
|
|
8
11
|
} from "./constants";
|
|
@@ -16,21 +19,27 @@ export {
|
|
|
16
19
|
createCreateTagHandler,
|
|
17
20
|
createTagHandler,
|
|
18
21
|
} from "./handlers/create-tag.write";
|
|
22
|
+
export {
|
|
23
|
+
createDeleteTagHandler,
|
|
24
|
+
deleteTagHandler,
|
|
25
|
+
} from "./handlers/delete-tag.write";
|
|
19
26
|
export {
|
|
20
27
|
createRemoveTagHandler,
|
|
21
28
|
removeTagHandler,
|
|
22
29
|
} from "./handlers/remove-tag.write";
|
|
23
30
|
export {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
} from "./handlers/
|
|
31
|
+
createUpdateTagHandler,
|
|
32
|
+
updateTagHandler,
|
|
33
|
+
} from "./handlers/update-tag.write";
|
|
27
34
|
export {
|
|
28
35
|
type AssignTagPayload,
|
|
29
36
|
assignTagPayloadSchema,
|
|
30
37
|
type CreateTagPayload,
|
|
31
38
|
createTagPayloadSchema,
|
|
39
|
+
type DeleteTagPayload,
|
|
40
|
+
deleteTagPayloadSchema,
|
|
32
41
|
type RemoveTagPayload,
|
|
33
|
-
type RenameTagPayload,
|
|
34
42
|
removeTagPayloadSchema,
|
|
35
|
-
|
|
43
|
+
type UpdateTagPayload,
|
|
44
|
+
updateTagPayloadSchema,
|
|
36
45
|
} 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
|
+
});
|