@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,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
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
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
|
|
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,
|
|
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 [
|
|
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
|
|
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
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
166
|
-
return true;
|
|
167
|
-
});
|
|
132
|
+
})();
|
|
168
133
|
};
|
|
169
134
|
|
|
170
135
|
return (
|
|
171
|
-
<div data-testid="tags-section" className="flex flex-col gap-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
id
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
|
207
|
-
onClick={() =>
|
|
208
|
-
testId="tags-section-
|
|
148
|
+
disabled={busy}
|
|
149
|
+
onClick={() => setPickerOpen(true)}
|
|
150
|
+
testId="tags-section-edit"
|
|
209
151
|
>
|
|
210
|
-
{busy ? t("tags.section.working") : t("tags.section.
|
|
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>
|