@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,390 @@
1
+ // @runtime client
2
+ // TagManager — the shared tag-catalog UI, GitLab-labels style. Built ONCE,
3
+ // mounted twice:
4
+ // - standalone: <TagManager /> → the Tags admin screen
5
+ // (see all labels + create/recolor/re-scope/delete, no select).
6
+ // - in a picker: <TagManager entityType selection /> → adds a select toggle
7
+ // per (scope-matching) tag and reports the chosen ids back.
8
+ //
9
+ // Catalog edits (create/update/delete) are immediate writes against the
10
+ // per-tenant catalog. Selection is buffered by the caller (TagPicker) and only
11
+ // applied on confirm — managing labels and picking them are separate concerns.
12
+
13
+ import {
14
+ useDispatcher,
15
+ usePrimitives,
16
+ useQuery,
17
+ useTranslation,
18
+ } from "@cosmicdrift/kumiko-renderer";
19
+ import { type ReactNode, useState } from "react";
20
+ import { TagsHandlers, TagsQueries } from "../constants";
21
+ import { TagChip } from "./tag-chip";
22
+
23
+ type TagRow = {
24
+ readonly id: string;
25
+ readonly name: string;
26
+ readonly color?: string | null;
27
+ readonly scope?: string | null;
28
+ readonly version: number;
29
+ };
30
+ type AssignmentRow = { readonly tagId: string };
31
+
32
+ // A small fixed palette so labels stay visually distinct without a color-wheel
33
+ // dependency; the hex field below still accepts any custom value.
34
+ const PRESET_COLORS = [
35
+ "#ef4444",
36
+ "#f97316",
37
+ "#eab308",
38
+ "#22c55e",
39
+ "#14b8a6",
40
+ "#3b82f6",
41
+ "#6366f1",
42
+ "#a855f7",
43
+ "#ec4899",
44
+ "#6b7280",
45
+ ] as const;
46
+
47
+ type Selection = {
48
+ readonly value: readonly string[];
49
+ readonly onChange: (next: readonly string[]) => void;
50
+ };
51
+
52
+ function ColorPicker({
53
+ value,
54
+ onChange,
55
+ idPrefix,
56
+ }: {
57
+ readonly value: string;
58
+ readonly onChange: (next: string) => void;
59
+ readonly idPrefix: string;
60
+ }): ReactNode {
61
+ const { Input } = usePrimitives();
62
+ return (
63
+ <div className="flex items-center gap-2">
64
+ <div className="flex flex-wrap gap-1">
65
+ {PRESET_COLORS.map((c) => (
66
+ // kumiko-lint-ignore primitives-discipline color swatch needs a raw colored button — Button is variant-styled
67
+ <button
68
+ key={c}
69
+ type="button"
70
+ aria-label={c}
71
+ data-testid={`${idPrefix}-swatch-${c}`}
72
+ onClick={() => onChange(c)}
73
+ style={{
74
+ width: 22,
75
+ height: 22,
76
+ borderRadius: 4,
77
+ backgroundColor: c,
78
+ border: value.toLowerCase() === c ? "2px solid #111827" : "1px solid #d1d5db",
79
+ }}
80
+ />
81
+ ))}
82
+ </div>
83
+ <div className="w-28">
84
+ <Input
85
+ kind="text"
86
+ id={`${idPrefix}-hex`}
87
+ name={`${idPrefix}-hex`}
88
+ value={value}
89
+ onChange={onChange}
90
+ placeholder="#22cc88"
91
+ />
92
+ </div>
93
+ </div>
94
+ );
95
+ }
96
+
97
+ export function TagManager({
98
+ entityType,
99
+ selection,
100
+ }: {
101
+ readonly entityType?: string;
102
+ readonly selection?: Selection;
103
+ }): ReactNode {
104
+ const { Banner, Button, Field, Input, Dialog, Text } = usePrimitives();
105
+ const t = useTranslation();
106
+ const dispatcher = useDispatcher();
107
+
108
+ const catalog = useQuery<{ rows: readonly TagRow[] }>(TagsQueries.tagList, {});
109
+ const assignments = useQuery<{ rows: readonly AssignmentRow[] }>(TagsQueries.assignmentList, {});
110
+
111
+ const [newName, setNewName] = useState("");
112
+ const [newColor, setNewColor] = useState("");
113
+ const [newScope, setNewScope] = useState(entityType ?? "");
114
+ const [editingId, setEditingId] = useState<string | null>(null);
115
+ const [editName, setEditName] = useState("");
116
+ const [editColor, setEditColor] = useState("");
117
+ const [editScope, setEditScope] = useState("");
118
+ const [deleting, setDeleting] = useState<TagRow | null>(null);
119
+ const [busy, setBusy] = useState(false);
120
+ const [errorKey, setErrorKey] = useState<string | null>(null);
121
+
122
+ if (catalog.loading && catalog.data === null) {
123
+ return (
124
+ <Banner variant="loading" testId="tag-manager-loading">
125
+ <Text>{t("tags.section.loading")}</Text>
126
+ </Banner>
127
+ );
128
+ }
129
+ const queryError = catalog.error ?? assignments.error;
130
+ if (queryError) {
131
+ return (
132
+ <Banner variant="error" testId="tag-manager-error">
133
+ <Text>{t(queryError.i18nKey, queryError.i18nParams)}</Text>
134
+ </Banner>
135
+ );
136
+ }
137
+
138
+ const usage = new Map<string, number>();
139
+ for (const a of assignments.data?.rows ?? []) usage.set(a.tagId, (usage.get(a.tagId) ?? 0) + 1);
140
+
141
+ const selecting = selection !== undefined;
142
+ const allTags = catalog.data?.rows ?? [];
143
+ const tags = selecting
144
+ ? allTags.filter((tag) => {
145
+ const scope = tag.scope ?? "";
146
+ return scope === "" || scope === entityType;
147
+ })
148
+ : allTags;
149
+
150
+ const refetch = async (): Promise<void> => {
151
+ await Promise.all([catalog.refetch(), assignments.refetch()]);
152
+ };
153
+
154
+ const apply = async (writes: () => Promise<boolean>): Promise<void> => {
155
+ setBusy(true);
156
+ setErrorKey(null);
157
+ try {
158
+ if (await writes()) await refetch();
159
+ } finally {
160
+ setBusy(false);
161
+ }
162
+ };
163
+
164
+ const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
165
+ const result = await dispatcher.write(type, payload);
166
+ if (!result.isSuccess) {
167
+ setErrorKey(result.error.i18nKey);
168
+ return false;
169
+ }
170
+ return true;
171
+ };
172
+
173
+ const createTag = (): void => {
174
+ const name = newName.trim();
175
+ if (name === "") return;
176
+ void apply(async () => {
177
+ const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, {
178
+ name,
179
+ ...(newColor.trim() !== "" && { color: newColor.trim() }),
180
+ ...(newScope.trim() !== "" && { scope: newScope.trim() }),
181
+ });
182
+ if (!created.isSuccess) {
183
+ setErrorKey(created.error.i18nKey);
184
+ return false;
185
+ }
186
+ setNewName("");
187
+ setNewColor("");
188
+ if (selection !== undefined) selection.onChange([...selection.value, created.data.id]);
189
+ return true;
190
+ });
191
+ };
192
+
193
+ const startEdit = (tag: TagRow): void => {
194
+ setEditingId(tag.id);
195
+ setEditName(tag.name);
196
+ setEditColor(tag.color ?? "");
197
+ setEditScope(tag.scope ?? "");
198
+ };
199
+
200
+ const saveEdit = (tag: TagRow): void => {
201
+ const name = editName.trim();
202
+ if (name === "") return;
203
+ void apply(async () => {
204
+ const ok = await writeOk(TagsHandlers.updateTag, {
205
+ id: tag.id,
206
+ version: tag.version,
207
+ name,
208
+ color: editColor.trim(),
209
+ scope: editScope.trim(),
210
+ });
211
+ if (ok) setEditingId(null);
212
+ return ok;
213
+ });
214
+ };
215
+
216
+ const confirmDelete = async (): Promise<void> => {
217
+ const target = deleting;
218
+ if (target === null) return;
219
+ await apply(async () => {
220
+ const ok = await writeOk(TagsHandlers.deleteTag, { id: target.id });
221
+ if (ok && selection?.value.includes(target.id)) {
222
+ selection.onChange(selection.value.filter((id) => id !== target.id));
223
+ }
224
+ return ok;
225
+ });
226
+ setDeleting(null);
227
+ };
228
+
229
+ const toggle = (id: string): void => {
230
+ if (selection === undefined) return;
231
+ const has = selection.value.includes(id);
232
+ selection.onChange(has ? selection.value.filter((x) => x !== id) : [...selection.value, id]);
233
+ };
234
+
235
+ return (
236
+ <div data-testid="tag-manager" className="flex flex-col gap-4">
237
+ {/* Create row */}
238
+ <div className="flex flex-col gap-2 rounded-md border p-3">
239
+ <Field id="tag-manager-new-name" label={t("tags.manage.newLabel")}>
240
+ <Input
241
+ kind="text"
242
+ id="tag-manager-new-name"
243
+ name="newTagName"
244
+ value={newName}
245
+ onChange={setNewName}
246
+ placeholder={t("tags.manage.namePlaceholder")}
247
+ />
248
+ </Field>
249
+ <ColorPicker value={newColor} onChange={setNewColor} idPrefix="tag-manager-new-color" />
250
+ {!selecting && (
251
+ <Field id="tag-manager-new-scope" label={t("tags.manage.scopeLabel")}>
252
+ <Input
253
+ kind="text"
254
+ id="tag-manager-new-scope"
255
+ name="newTagScope"
256
+ value={newScope}
257
+ onChange={setNewScope}
258
+ placeholder={t("tags.manage.scopePlaceholder")}
259
+ />
260
+ </Field>
261
+ )}
262
+ <div>
263
+ <Button
264
+ variant="primary"
265
+ disabled={busy || newName.trim() === ""}
266
+ onClick={() => createTag()}
267
+ testId="tag-manager-create"
268
+ >
269
+ {busy ? t("tags.section.working") : t("tags.manage.create")}
270
+ </Button>
271
+ </div>
272
+ </div>
273
+
274
+ {/* Catalog list */}
275
+ {tags.length === 0 ? (
276
+ <Banner variant="info" testId="tag-manager-empty">
277
+ <Text>{t("tags.section.empty")}</Text>
278
+ </Banner>
279
+ ) : (
280
+ <div className="flex flex-col gap-1">
281
+ {tags.map((tag) =>
282
+ editingId === tag.id ? (
283
+ <div
284
+ key={tag.id}
285
+ data-testid={`tag-manager-edit-${tag.id}`}
286
+ className="flex flex-col gap-2 rounded-md border p-3"
287
+ >
288
+ <Input
289
+ kind="text"
290
+ id={`tag-edit-name-${tag.id}`}
291
+ name="editName"
292
+ value={editName}
293
+ onChange={setEditName}
294
+ />
295
+ <ColorPicker
296
+ value={editColor}
297
+ onChange={setEditColor}
298
+ idPrefix={`tag-edit-color-${tag.id}`}
299
+ />
300
+ <Input
301
+ kind="text"
302
+ id={`tag-edit-scope-${tag.id}`}
303
+ name="editScope"
304
+ value={editScope}
305
+ onChange={setEditScope}
306
+ placeholder={t("tags.manage.scopePlaceholder")}
307
+ />
308
+ <div className="flex gap-2">
309
+ <Button
310
+ variant="primary"
311
+ disabled={busy}
312
+ onClick={() => saveEdit(tag)}
313
+ testId={`tag-manager-save-${tag.id}`}
314
+ >
315
+ {t("tags.manage.save")}
316
+ </Button>
317
+ <Button variant="secondary" disabled={busy} onClick={() => setEditingId(null)}>
318
+ {t("tags.manage.cancel")}
319
+ </Button>
320
+ </div>
321
+ </div>
322
+ ) : (
323
+ <div
324
+ key={tag.id}
325
+ data-testid={`tag-manager-row-${tag.id}`}
326
+ className="flex items-center gap-2 rounded-md px-2 py-1 hover:bg-muted"
327
+ >
328
+ {selecting && (
329
+ <Button
330
+ variant={selection?.value.includes(tag.id) ? "primary" : "secondary"}
331
+ onClick={() => toggle(tag.id)}
332
+ testId={`tag-manager-toggle-${tag.id}`}
333
+ >
334
+ {selection?.value.includes(tag.id) ? "✓" : "+"}
335
+ </Button>
336
+ )}
337
+ <TagChip name={tag.name} color={tag.color} />
338
+ {!selecting && (tag.scope ?? "") !== "" && (
339
+ <Text variant="small">{`@${tag.scope}`}</Text>
340
+ )}
341
+ <Text variant="small">
342
+ {t("tags.manage.usage", { count: usage.get(tag.id) ?? 0 })}
343
+ </Text>
344
+ <div className="ml-auto flex gap-1">
345
+ <Button
346
+ variant="secondary"
347
+ disabled={busy}
348
+ onClick={() => startEdit(tag)}
349
+ testId={`tag-manager-edit-btn-${tag.id}`}
350
+ >
351
+ {t("tags.manage.edit")}
352
+ </Button>
353
+ <Button
354
+ variant="danger"
355
+ disabled={busy}
356
+ onClick={() => setDeleting(tag)}
357
+ testId={`tag-manager-delete-btn-${tag.id}`}
358
+ >
359
+ {t("tags.manage.delete")}
360
+ </Button>
361
+ </div>
362
+ </div>
363
+ ),
364
+ )}
365
+ </div>
366
+ )}
367
+
368
+ {errorKey !== null && (
369
+ <Banner variant="error" testId="tag-manager-action-error">
370
+ <Text>{t(errorKey)}</Text>
371
+ </Banner>
372
+ )}
373
+
374
+ <Dialog
375
+ open={deleting !== null}
376
+ onOpenChange={(open) => {
377
+ if (!open) setDeleting(null);
378
+ }}
379
+ title={t("tags.manage.deleteConfirmTitle", { name: deleting?.name ?? "" })}
380
+ description={t("tags.manage.deleteConfirmDesc", {
381
+ count: usage.get(deleting?.id ?? "") ?? 0,
382
+ })}
383
+ confirmLabel={t("tags.manage.delete")}
384
+ variant="danger"
385
+ onConfirm={confirmDelete}
386
+ testId="tag-manager-delete-dialog"
387
+ />
388
+ </div>
389
+ );
390
+ }
@@ -0,0 +1,51 @@
1
+ // @runtime client
2
+ // TagPicker — the shared "manage + pick labels" modal. Wraps TagManager in
3
+ // select-mode inside a Dialog: the user can create/recolor/delete labels (those
4
+ // hit the catalog immediately) and toggle which ones apply (buffered). On
5
+ // confirm ("Done") the chosen ids are handed back to the caller via onChange;
6
+ // Cancel/✕ discards the selection (catalog edits stay). Scope-filtered to the
7
+ // caller's entityType so only global + matching labels are offered.
8
+
9
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
10
+ import { type ReactNode, useEffect, useState } from "react";
11
+ import { TagManager } from "./tag-manager";
12
+
13
+ export function TagPicker({
14
+ entityType,
15
+ value,
16
+ onChange,
17
+ open,
18
+ onOpenChange,
19
+ }: {
20
+ readonly entityType?: string;
21
+ readonly value: readonly string[];
22
+ readonly onChange: (next: readonly string[]) => void;
23
+ readonly open: boolean;
24
+ readonly onOpenChange: (open: boolean) => void;
25
+ }): ReactNode {
26
+ const { Dialog } = usePrimitives();
27
+ const t = useTranslation();
28
+ const [buffer, setBuffer] = useState<readonly string[]>(value);
29
+ // Reset the buffer to the caller's truth every time the modal (re)opens.
30
+ useEffect(() => {
31
+ if (open) setBuffer(value);
32
+ }, [open, value]);
33
+
34
+ return (
35
+ <Dialog
36
+ open={open}
37
+ onOpenChange={onOpenChange}
38
+ title={t("tags.picker.title")}
39
+ confirmLabel={t("tags.picker.done")}
40
+ onConfirm={async () => {
41
+ onChange(buffer);
42
+ }}
43
+ testId="tag-picker-dialog"
44
+ >
45
+ <TagManager
46
+ {...(entityType !== undefined && { entityType })}
47
+ selection={{ value: buffer, onChange: setBuffer }}
48
+ />
49
+ </Dialog>
50
+ );
51
+ }
@@ -1,9 +1,10 @@
1
1
  // @runtime client
2
- // TagSection — drop-in tag manager for ANY entity, GitLab-labels style: one
3
- // searchable multi-combobox showing the entity's tags as chips, with a compact
4
- // row below to create-and-attach a brand-new tag. Tag writes are immediate
5
- // (assign/remove are idempotent), so the section owns its state and refetches
6
- // after each action it is NOT part of a host form's save.
2
+ // TagSection — drop-in tag editor for ANY entity, GitLab-labels style. Shows the
3
+ // entity's tags as colored chips plus an "Edit tags" button that opens the
4
+ // shared TagPicker modal (pick + manage). Applying the picker diffs the
5
+ // selection against the current assignments and runs idempotent assign/remove
6
+ // writes so the section owns its state and refetches after each change; it is
7
+ // NOT part of a host form's save.
7
8
  //
8
9
  // Two ways to mount (both need tagsClient() registered once, for i18n):
9
10
  // - standalone: <TagSection entityName="note" entityId={noteId} />
@@ -19,6 +20,8 @@ import {
19
20
  } from "@cosmicdrift/kumiko-renderer";
20
21
  import { type ReactNode, useState } from "react";
21
22
  import { TagsHandlers, TagsQueries } from "../constants";
23
+ import { TagChip } from "./tag-chip";
24
+ import { TagPicker } from "./tag-picker";
22
25
 
23
26
  type TagRow = { readonly id: string; readonly name: string; readonly color?: string | null };
24
27
  type AssignmentRow = {
@@ -29,9 +32,7 @@ type AssignmentRow = {
29
32
  type TagListResponse = { readonly rows: readonly TagRow[] };
30
33
  type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
31
34
 
32
- // What changed between the entity's current tags and the combobox's new
33
- // selection. A single combobox toggle yields one add or one remove; the diff
34
- // stays correct for a batch selection too.
35
+ // What changed between the entity's current tags and the picker's new selection.
35
36
  export function tagSelectionDelta(
36
37
  prev: readonly string[],
37
38
  next: readonly string[],
@@ -51,7 +52,7 @@ export function TagSection({
51
52
  readonly entityName: string;
52
53
  readonly entityId: string | null;
53
54
  }): ReactNode {
54
- const { Banner, Button, Field, Input, Text } = usePrimitives();
55
+ const { Banner, Button, Text } = usePrimitives();
55
56
  const t = useTranslation();
56
57
  const dispatcher = useDispatcher();
57
58
  const enabled = entityId !== null;
@@ -61,7 +62,7 @@ export function TagSection({
61
62
  { filter: { field: "entityId", op: "eq", value: entityId } },
62
63
  { enabled },
63
64
  );
64
- const [newName, setNewName] = useState("");
65
+ const [pickerOpen, setPickerOpen] = useState(false);
65
66
  const [busy, setBusy] = useState(false);
66
67
  const [errorKey, setErrorKey] = useState<string | null>(null);
67
68
 
@@ -91,34 +92,15 @@ export function TagSection({
91
92
  );
92
93
  }
93
94
 
94
- const catalogTags = catalog.data?.rows ?? [];
95
+ const byId = new Map((catalog.data?.rows ?? []).map((tg) => [tg.id, tg]));
95
96
  const assignedIds = (assignments.data?.rows ?? [])
96
97
  .filter((r) => r.entityType === entityName)
97
98
  .map((r) => r.tagId);
98
- // Catalog drives the options; an assigned tag missing from the catalog (none
99
- // in v1 — no delete-tag yet) is appended so it stays removable.
100
- const nameById = new Map(catalogTags.map((tg) => [tg.id, tg.name]));
101
- const options = [...new Set([...catalogTags.map((tg) => tg.id), ...assignedIds])].map((id) => ({
102
- value: id,
103
- label: nameById.get(id) ?? id,
104
- }));
105
99
 
106
100
  const refetch = async (): Promise<void> => {
107
101
  await Promise.all([catalog.refetch(), assignments.refetch()]);
108
102
  };
109
103
 
110
- // Runs a write-sequence (each step returns false + sets errorKey on failure,
111
- // stopping the sequence) and refetches to server-truth when it completes.
112
- const apply = async (writes: () => Promise<boolean>): Promise<void> => {
113
- setBusy(true);
114
- setErrorKey(null);
115
- try {
116
- if (await writes()) await refetch();
117
- } finally {
118
- setBusy(false);
119
- }
120
- };
121
-
122
104
  const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
123
105
  const result = await dispatcher.write(type, payload);
124
106
  if (!result.isSuccess) {
@@ -128,89 +110,57 @@ export function TagSection({
128
110
  return true;
129
111
  };
130
112
 
131
- const onSelectionChange = (next: readonly string[]): void => {
113
+ const onPicked = (next: readonly string[]): void => {
132
114
  const { added, removed } = tagSelectionDelta(assignedIds, next);
133
115
  if (added.length === 0 && removed.length === 0) return;
134
- void apply(async () => {
135
- for (const tagId of added) {
136
- if (!(await writeOk(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId })))
137
- return false;
138
- }
139
- for (const tagId of removed) {
140
- if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
141
- return false;
142
- }
143
- return true;
144
- });
145
- };
146
-
147
- const createAndAssign = (): void => {
148
- const name = newName.trim();
149
- if (name === "") return;
150
- void apply(async () => {
151
- const created = await dispatcher.write<{ id: string }>(TagsHandlers.createTag, { name });
152
- if (!created.isSuccess) {
153
- setErrorKey(created.error.i18nKey);
154
- return false;
155
- }
156
- if (
157
- !(await writeOk(TagsHandlers.assignTag, {
158
- tagId: created.data.id,
159
- entityType: entityName,
160
- entityId,
161
- }))
162
- ) {
163
- return false;
116
+ setBusy(true);
117
+ setErrorKey(null);
118
+ void (async () => {
119
+ try {
120
+ for (const tagId of added) {
121
+ if (!(await writeOk(TagsHandlers.assignTag, { tagId, entityType: entityName, entityId })))
122
+ return;
123
+ }
124
+ for (const tagId of removed) {
125
+ if (!(await writeOk(TagsHandlers.removeTag, { tagId, entityType: entityName, entityId })))
126
+ return;
127
+ }
128
+ await refetch();
129
+ } finally {
130
+ setBusy(false);
164
131
  }
165
- setNewName("");
166
- return true;
167
- });
132
+ })();
168
133
  };
169
134
 
170
135
  return (
171
- <div data-testid="tags-section" className="flex flex-col gap-4">
172
- <Field id="tags-section-select" label={t("tags.section.label")}>
173
- <Input
174
- kind="combobox"
175
- multiple
176
- id="tags-section-select"
177
- name="tags"
178
- options={options}
179
- value={assignedIds}
180
- onChange={onSelectionChange}
181
- disabled={busy}
182
- placeholder={t("tags.section.placeholder")}
183
- emptyText={t("tags.section.empty")}
184
- />
185
- </Field>
186
-
187
- {/* Inline create-row: das Label-Input wächst, der Add-Button sitzt
188
- rechts daneben (items-end → bündig zur Input-Unterkante).
189
- ponytail: separate row, weil die Combobox keine create-on-type-
190
- Affordance hat. Fold-in, wenn der renderer-web-Combobox ein
191
- freeSolo/onCreate-Prop bekommt. */}
192
- <div className="flex items-end gap-2">
193
- <div className="flex-1">
194
- <Field id="tags-section-new" label={t("tags.section.newLabel")}>
195
- <Input
196
- kind="text"
197
- id="tags-section-new"
198
- name="newTag"
199
- value={newName}
200
- onChange={setNewName}
201
- />
202
- </Field>
203
- </div>
136
+ <div data-testid="tags-section" className="flex flex-col gap-2">
137
+ <div className="flex flex-wrap items-center gap-1">
138
+ {assignedIds.length === 0 ? (
139
+ <Text variant="small">{t("tags.section.none")}</Text>
140
+ ) : (
141
+ assignedIds.map((id) => {
142
+ const tag = byId.get(id);
143
+ return <TagChip key={id} name={tag?.name ?? id} color={tag?.color} />;
144
+ })
145
+ )}
204
146
  <Button
205
147
  variant="secondary"
206
- disabled={busy || newName.trim() === ""}
207
- onClick={() => createAndAssign()}
208
- testId="tags-section-create"
148
+ disabled={busy}
149
+ onClick={() => setPickerOpen(true)}
150
+ testId="tags-section-edit"
209
151
  >
210
- {busy ? t("tags.section.working") : t("tags.section.create")}
152
+ {busy ? t("tags.section.working") : t("tags.section.edit")}
211
153
  </Button>
212
154
  </div>
213
155
 
156
+ <TagPicker
157
+ entityType={entityName}
158
+ value={assignedIds}
159
+ onChange={onPicked}
160
+ open={pickerOpen}
161
+ onOpenChange={setPickerOpen}
162
+ />
163
+
214
164
  {errorKey !== null && (
215
165
  <Banner variant="error" testId="tags-section-action-error">
216
166
  <Text>{t(errorKey)}</Text>