@cosmicdrift/kumiko-bundled-features 0.89.0 → 0.90.2
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 +9 -6
- package/src/folders/__tests__/drift.test.ts +43 -0
- package/src/folders/__tests__/feature.test.ts +168 -0
- package/src/folders/__tests__/folders.integration.test.ts +290 -0
- package/src/folders/aggregate-id.ts +23 -0
- package/src/folders/constants.ts +40 -0
- package/src/folders/entity.ts +42 -0
- package/src/folders/executor.ts +11 -0
- package/src/folders/feature.ts +106 -0
- package/src/folders/handlers/clear-folder.write.ts +35 -0
- package/src/folders/handlers/set-folder.write.ts +82 -0
- package/src/folders/index.ts +23 -0
- package/src/folders/schemas.ts +18 -0
- package/src/folders/web/__tests__/folder-section.test.tsx +181 -0
- package/src/folders/web/__tests__/tree.test.ts +58 -0
- package/src/folders/web/client-plugin.tsx +16 -0
- package/src/folders/web/folder-manager.tsx +323 -0
- package/src/folders/web/folder-section.tsx +198 -0
- package/src/folders/web/i18n.ts +55 -0
- package/src/folders/web/index.ts +6 -0
- package/src/folders/web/tree.ts +54 -0
- package/src/folders-user-data/hooks.ts +58 -0
- package/src/folders-user-data/index.ts +33 -0
- package/src/user-data-rights/feature.ts +1 -1
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// FolderManager — in-screen folder tree the user manages directly: create root
|
|
3
|
+
// folders + subfolders, rename, delete, collapse/expand. KPI-agnostic: a host
|
|
4
|
+
// passes `renderMeta` to hang its own per-folder badges (e.g. money-horse's
|
|
5
|
+
// rolled-up Restschuld/Rate) next to each node — the manager knows nothing about
|
|
6
|
+
// what's filed in a folder, only the folder tree itself.
|
|
7
|
+
//
|
|
8
|
+
// Finder/Explorer-style: the tree is flattened to its visible rows in DFS order
|
|
9
|
+
// and rendered as full-width rows with (a) alternating zebra striping, (b) one
|
|
10
|
+
// vertical guide line per ancestor depth, and (c) a disclosure chevron + folder
|
|
11
|
+
// icon. Icons are lucide-react (the same set the nav's NAV_ICONS uses), so they
|
|
12
|
+
// match the rest of the app. Actions are compact always-visible icon buttons (NOT
|
|
13
|
+
// hover-only — static screenshots can't hover). Flattening (vs nested divs) is
|
|
14
|
+
// what lets the zebra + guide lines run edge-to-edge and stay aligned regardless
|
|
15
|
+
// of depth.
|
|
16
|
+
//
|
|
17
|
+
// NOTE on Tailwind: a host's Tailwind v4 build must `@source`-scan this package's
|
|
18
|
+
// src (node_modules is ignored by default) or these classes never compile. money-
|
|
19
|
+
// horse already does; that scan is a real shipping requirement for any host.
|
|
20
|
+
//
|
|
21
|
+
// Folder writes are immediate; the manager owns its catalog query and refetches
|
|
22
|
+
// after each action.
|
|
23
|
+
|
|
24
|
+
import {
|
|
25
|
+
useDispatcher,
|
|
26
|
+
usePrimitives,
|
|
27
|
+
useQuery,
|
|
28
|
+
useTranslation,
|
|
29
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
30
|
+
import {
|
|
31
|
+
ChevronDown,
|
|
32
|
+
ChevronRight,
|
|
33
|
+
Folder,
|
|
34
|
+
type LucideIcon,
|
|
35
|
+
Pencil,
|
|
36
|
+
Plus,
|
|
37
|
+
Trash2,
|
|
38
|
+
} from "lucide-react";
|
|
39
|
+
import { type ReactNode, useState } from "react";
|
|
40
|
+
import { FoldersHandlers, FoldersQueries } from "../constants";
|
|
41
|
+
import { buildFolderTree, type FolderNode, type FolderRow } from "./tree";
|
|
42
|
+
|
|
43
|
+
type FolderListResponse = { readonly rows: readonly FolderRow[] };
|
|
44
|
+
|
|
45
|
+
type Pending =
|
|
46
|
+
| { readonly mode: "create"; readonly parentId: string | null }
|
|
47
|
+
| { readonly mode: "rename"; readonly id: string; readonly version: number }
|
|
48
|
+
| null;
|
|
49
|
+
|
|
50
|
+
export function FolderManager({
|
|
51
|
+
renderMeta,
|
|
52
|
+
}: {
|
|
53
|
+
// Optional per-folder slot (right side of each row). The host owns the data;
|
|
54
|
+
// the manager just gives it a place to render.
|
|
55
|
+
readonly renderMeta?: (folder: FolderRow) => ReactNode;
|
|
56
|
+
}): ReactNode {
|
|
57
|
+
const { Banner, Button, Input, Text } = usePrimitives();
|
|
58
|
+
const t = useTranslation();
|
|
59
|
+
const dispatcher = useDispatcher();
|
|
60
|
+
const catalog = useQuery<FolderListResponse>(FoldersQueries.folderList, {});
|
|
61
|
+
const [pending, setPending] = useState<Pending>(null);
|
|
62
|
+
const [draftName, setDraftName] = useState("");
|
|
63
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(new Set());
|
|
64
|
+
const [busy, setBusy] = useState(false);
|
|
65
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
66
|
+
|
|
67
|
+
if (catalog.loading && catalog.data === null) {
|
|
68
|
+
return (
|
|
69
|
+
<Banner variant="loading" testId="folder-manager-loading">
|
|
70
|
+
<Text>{t("folders.manager.loading")}</Text>
|
|
71
|
+
</Banner>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (catalog.error) {
|
|
75
|
+
return (
|
|
76
|
+
<Banner variant="error" testId="folder-manager-error">
|
|
77
|
+
<Text>{t(catalog.error.i18nKey, catalog.error.i18nParams)}</Text>
|
|
78
|
+
</Banner>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const rows = catalog.data?.rows ?? [];
|
|
83
|
+
const tree = buildFolderTree(rows);
|
|
84
|
+
|
|
85
|
+
const toggleCollapse = (id: string): void =>
|
|
86
|
+
setCollapsed((prev) => {
|
|
87
|
+
const next = new Set(prev);
|
|
88
|
+
if (next.has(id)) next.delete(id);
|
|
89
|
+
else next.add(id);
|
|
90
|
+
return next;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
const openCreate = (parentId: string | null): void => {
|
|
94
|
+
setErrorKey(null);
|
|
95
|
+
setDraftName("");
|
|
96
|
+
setPending({ mode: "create", parentId });
|
|
97
|
+
if (parentId !== null)
|
|
98
|
+
setCollapsed((prev) => new Set([...prev].filter((id) => id !== parentId)));
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const openRename = (folder: FolderRow): void => {
|
|
102
|
+
setErrorKey(null);
|
|
103
|
+
setDraftName(folder.name);
|
|
104
|
+
setPending({ mode: "rename", id: folder.id, version: folder.version });
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const apply = async (write: () => Promise<boolean>): Promise<void> => {
|
|
108
|
+
setBusy(true);
|
|
109
|
+
setErrorKey(null);
|
|
110
|
+
try {
|
|
111
|
+
if (await write()) {
|
|
112
|
+
setPending(null);
|
|
113
|
+
await catalog.refetch();
|
|
114
|
+
}
|
|
115
|
+
} finally {
|
|
116
|
+
setBusy(false);
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
121
|
+
const result = await dispatcher.write(type, payload);
|
|
122
|
+
if (!result.isSuccess) {
|
|
123
|
+
setErrorKey(result.error.i18nKey);
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
return true;
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const saveDraft = (): void => {
|
|
130
|
+
const name = draftName.trim();
|
|
131
|
+
if (name === "" || pending === null) return;
|
|
132
|
+
const p = pending;
|
|
133
|
+
void apply(() =>
|
|
134
|
+
p.mode === "create"
|
|
135
|
+
? writeOk(
|
|
136
|
+
FoldersHandlers.createFolder,
|
|
137
|
+
p.parentId === null ? { name } : { name, parentId: p.parentId },
|
|
138
|
+
)
|
|
139
|
+
: writeOk(FoldersHandlers.updateFolder, {
|
|
140
|
+
id: p.id,
|
|
141
|
+
version: p.version,
|
|
142
|
+
changes: { name },
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const deleteFolder = (node: FolderNode): void => {
|
|
148
|
+
if (node.children.length > 0) {
|
|
149
|
+
setErrorKey("folders.manager.deleteBlocked");
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
void apply(() => writeOk(FoldersHandlers.deleteFolder, { id: node.id }));
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// One vertical guide line per ancestor depth, centered in a chevron-width column;
|
|
156
|
+
// self-stretch makes consecutive rows' lines join into continuous rails.
|
|
157
|
+
const rails = (depth: number): ReactNode =>
|
|
158
|
+
Array.from({ length: depth }, (_, i) => (
|
|
159
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: positional rail, no identity
|
|
160
|
+
<span key={i} className="flex w-5 shrink-0 justify-center self-stretch" aria-hidden="true">
|
|
161
|
+
<span className="w-px self-stretch bg-border" />
|
|
162
|
+
</span>
|
|
163
|
+
));
|
|
164
|
+
|
|
165
|
+
const rowClass = (stripe: boolean): string =>
|
|
166
|
+
`flex items-center gap-1.5 px-2 py-1.5 transition-colors hover:bg-muted/60 ${
|
|
167
|
+
stripe ? "bg-muted/40" : ""
|
|
168
|
+
}`;
|
|
169
|
+
|
|
170
|
+
const actionButton = (
|
|
171
|
+
label: string,
|
|
172
|
+
testId: string,
|
|
173
|
+
Icon: LucideIcon,
|
|
174
|
+
onClick: () => void,
|
|
175
|
+
danger?: boolean,
|
|
176
|
+
): ReactNode => (
|
|
177
|
+
// kumiko-lint-ignore primitives-discipline: compact icon action for the Finder-style tree row; the full Button primitive would break the dense single-row layout
|
|
178
|
+
<button
|
|
179
|
+
type="button"
|
|
180
|
+
disabled={busy}
|
|
181
|
+
onClick={onClick}
|
|
182
|
+
aria-label={label}
|
|
183
|
+
title={label}
|
|
184
|
+
data-testid={testId}
|
|
185
|
+
className={`rounded p-1 text-muted-foreground transition-colors hover:bg-background disabled:opacity-40 ${
|
|
186
|
+
danger ? "hover:text-destructive" : "hover:text-foreground"
|
|
187
|
+
}`}
|
|
188
|
+
>
|
|
189
|
+
<Icon size={16} aria-hidden="true" />
|
|
190
|
+
</button>
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
const draftRow = (depth: number, key: string, stripe: boolean): ReactNode => (
|
|
194
|
+
<div key={key} className={rowClass(stripe)} data-testid="folder-manager-draft">
|
|
195
|
+
{rails(depth)}
|
|
196
|
+
<Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground/50" />
|
|
197
|
+
<div className="flex-1">
|
|
198
|
+
<Input
|
|
199
|
+
kind="text"
|
|
200
|
+
id="folder-manager-draft"
|
|
201
|
+
name="folderName"
|
|
202
|
+
value={draftName}
|
|
203
|
+
onChange={setDraftName}
|
|
204
|
+
placeholder={t("folders.manager.newRoot")}
|
|
205
|
+
/>
|
|
206
|
+
</div>
|
|
207
|
+
<Button
|
|
208
|
+
variant="primary"
|
|
209
|
+
disabled={busy || draftName.trim() === ""}
|
|
210
|
+
onClick={() => saveDraft()}
|
|
211
|
+
testId="folder-manager-save"
|
|
212
|
+
>
|
|
213
|
+
{busy ? t("folders.manager.working") : t("folders.manager.save")}
|
|
214
|
+
</Button>
|
|
215
|
+
<Button variant="secondary" disabled={busy} onClick={() => setPending(null)}>
|
|
216
|
+
{t("folders.manager.cancel")}
|
|
217
|
+
</Button>
|
|
218
|
+
</div>
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const folderRow = (node: FolderNode, depth: number, stripe: boolean): ReactNode => {
|
|
222
|
+
const hasChildren = node.children.length > 0;
|
|
223
|
+
const expanded = hasChildren && !collapsed.has(node.id);
|
|
224
|
+
return (
|
|
225
|
+
<div key={node.id} data-testid={`folder-node-${node.id}`} className={rowClass(stripe)}>
|
|
226
|
+
{rails(depth)}
|
|
227
|
+
{hasChildren ? (
|
|
228
|
+
// kumiko-lint-ignore primitives-discipline: compact disclosure chevron, not a form button
|
|
229
|
+
<button
|
|
230
|
+
type="button"
|
|
231
|
+
className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-background hover:text-foreground"
|
|
232
|
+
aria-expanded={expanded}
|
|
233
|
+
onClick={() => toggleCollapse(node.id)}
|
|
234
|
+
data-testid={`folder-toggle-${node.id}`}
|
|
235
|
+
>
|
|
236
|
+
{expanded ? (
|
|
237
|
+
<ChevronDown size={16} aria-hidden="true" />
|
|
238
|
+
) : (
|
|
239
|
+
<ChevronRight size={16} aria-hidden="true" />
|
|
240
|
+
)}
|
|
241
|
+
</button>
|
|
242
|
+
) : (
|
|
243
|
+
<span className="w-5 shrink-0" />
|
|
244
|
+
)}
|
|
245
|
+
<Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
|
|
246
|
+
<span className="flex-1 truncate font-medium">{node.name}</span>
|
|
247
|
+
{renderMeta?.(node)}
|
|
248
|
+
<div className="flex items-center gap-0.5">
|
|
249
|
+
{actionButton(t("folders.manager.addChild"), `folder-add-child-${node.id}`, Plus, () =>
|
|
250
|
+
openCreate(node.id),
|
|
251
|
+
)}
|
|
252
|
+
{actionButton(t("folders.manager.rename"), `folder-rename-${node.id}`, Pencil, () =>
|
|
253
|
+
openRename(node),
|
|
254
|
+
)}
|
|
255
|
+
{actionButton(
|
|
256
|
+
t("folders.manager.delete"),
|
|
257
|
+
`folder-delete-${node.id}`,
|
|
258
|
+
Trash2,
|
|
259
|
+
() => deleteFolder(node),
|
|
260
|
+
true,
|
|
261
|
+
)}
|
|
262
|
+
</div>
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
};
|
|
266
|
+
|
|
267
|
+
// Flatten the visible tree (DFS) into ordered rows so the zebra index + guide
|
|
268
|
+
// rails stay correct across depth. A pending rename swaps a node's row in place;
|
|
269
|
+
// a pending create appends a draft row at the end of its parent's children.
|
|
270
|
+
const out: ReactNode[] = [];
|
|
271
|
+
let idx = 0;
|
|
272
|
+
const stripeNext = (): boolean => {
|
|
273
|
+
const stripe = idx % 2 === 1;
|
|
274
|
+
idx += 1;
|
|
275
|
+
return stripe;
|
|
276
|
+
};
|
|
277
|
+
const walk = (nodes: readonly FolderNode[], depth: number): void => {
|
|
278
|
+
for (const node of nodes) {
|
|
279
|
+
const renaming = pending?.mode === "rename" && pending.id === node.id;
|
|
280
|
+
out.push(
|
|
281
|
+
renaming
|
|
282
|
+
? draftRow(depth, `rename-${node.id}`, stripeNext())
|
|
283
|
+
: folderRow(node, depth, stripeNext()),
|
|
284
|
+
);
|
|
285
|
+
if (node.children.length > 0 && !collapsed.has(node.id)) walk(node.children, depth + 1);
|
|
286
|
+
if (pending?.mode === "create" && pending.parentId === node.id)
|
|
287
|
+
out.push(draftRow(depth + 1, `create-under-${node.id}`, stripeNext()));
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
walk(tree, 0);
|
|
291
|
+
|
|
292
|
+
const creatingRoot = pending?.mode === "create" && pending.parentId === null;
|
|
293
|
+
if (creatingRoot) out.unshift(draftRow(0, "create-root", false));
|
|
294
|
+
|
|
295
|
+
return (
|
|
296
|
+
<div data-testid="folder-manager" className="flex flex-col gap-2">
|
|
297
|
+
<div className="flex justify-end">
|
|
298
|
+
<Button
|
|
299
|
+
variant="primary"
|
|
300
|
+
disabled={busy || creatingRoot}
|
|
301
|
+
onClick={() => openCreate(null)}
|
|
302
|
+
testId="folder-manager-new-root"
|
|
303
|
+
>
|
|
304
|
+
{t("folders.manager.newRoot")}
|
|
305
|
+
</Button>
|
|
306
|
+
</div>
|
|
307
|
+
|
|
308
|
+
{tree.length === 0 && !creatingRoot ? (
|
|
309
|
+
<Banner variant="info" testId="folder-manager-empty">
|
|
310
|
+
<Text>{t("folders.manager.empty")}</Text>
|
|
311
|
+
</Banner>
|
|
312
|
+
) : (
|
|
313
|
+
<div className="overflow-hidden rounded-md border">{out}</div>
|
|
314
|
+
)}
|
|
315
|
+
|
|
316
|
+
{errorKey !== null && (
|
|
317
|
+
<Banner variant="error" testId="folder-manager-action-error">
|
|
318
|
+
<Text>{t(errorKey)}</Text>
|
|
319
|
+
</Banner>
|
|
320
|
+
)}
|
|
321
|
+
</div>
|
|
322
|
+
);
|
|
323
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// FolderSection — drop-in single-folder picker for ANY entity. Unlike tags
|
|
3
|
+
// (many-to-many multi-combobox), an entity lives in exactly ONE folder, so this
|
|
4
|
+
// is a single-select combobox with a "no folder" option plus a compact row to
|
|
5
|
+
// create-and-file a brand-new folder. Folder writes are immediate (set/clear are
|
|
6
|
+
// idempotent), so the section owns its state and refetches after each action —
|
|
7
|
+
// it is NOT part of a host form's save.
|
|
8
|
+
//
|
|
9
|
+
// Two ways to mount (both need foldersClient() registered once, for i18n):
|
|
10
|
+
// - standalone: <FolderSection entityName="credit" entityId={creditId} />
|
|
11
|
+
// - extension: a screen-schema section with
|
|
12
|
+
// component: { react: { __component: FOLDER_SECTION_EXTENSION_NAME } }
|
|
13
|
+
// (RenderEdit passes { entityName, entityId }).
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
useDispatcher,
|
|
17
|
+
usePrimitives,
|
|
18
|
+
useQuery,
|
|
19
|
+
useTranslation,
|
|
20
|
+
} from "@cosmicdrift/kumiko-renderer";
|
|
21
|
+
import { type ReactNode, useState } from "react";
|
|
22
|
+
import { FoldersHandlers, FoldersQueries } from "../constants";
|
|
23
|
+
import { type FolderRow, folderPath } from "./tree";
|
|
24
|
+
|
|
25
|
+
type AssignmentRow = {
|
|
26
|
+
readonly folderId: string;
|
|
27
|
+
readonly entityType: string;
|
|
28
|
+
readonly entityId: string;
|
|
29
|
+
};
|
|
30
|
+
type FolderListResponse = { readonly rows: readonly FolderRow[] };
|
|
31
|
+
type AssignmentListResponse = { readonly rows: readonly AssignmentRow[] };
|
|
32
|
+
|
|
33
|
+
// Sentinel for the "no folder" combobox option — selecting it clears the
|
|
34
|
+
// assignment. Empty string can't collide with a folder id (uuid).
|
|
35
|
+
const NO_FOLDER = "";
|
|
36
|
+
|
|
37
|
+
export function FolderSection({
|
|
38
|
+
entityName,
|
|
39
|
+
entityId,
|
|
40
|
+
}: {
|
|
41
|
+
readonly entityName: string;
|
|
42
|
+
readonly entityId: string | null;
|
|
43
|
+
}): ReactNode {
|
|
44
|
+
const { Banner, Button, Field, Input, Text } = usePrimitives();
|
|
45
|
+
const t = useTranslation();
|
|
46
|
+
const dispatcher = useDispatcher();
|
|
47
|
+
const enabled = entityId !== null;
|
|
48
|
+
const catalog = useQuery<FolderListResponse>(FoldersQueries.folderList, {}, { enabled });
|
|
49
|
+
const assignments = useQuery<AssignmentListResponse>(
|
|
50
|
+
FoldersQueries.assignmentList,
|
|
51
|
+
{ filter: { field: "entityId", op: "eq", value: entityId } },
|
|
52
|
+
{ enabled },
|
|
53
|
+
);
|
|
54
|
+
const [newName, setNewName] = useState("");
|
|
55
|
+
const [busy, setBusy] = useState(false);
|
|
56
|
+
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
57
|
+
|
|
58
|
+
if (entityId === null) {
|
|
59
|
+
return (
|
|
60
|
+
<Banner variant="info" testId="folder-section-create-mode">
|
|
61
|
+
<Text>{t("folders.section.createMode")}</Text>
|
|
62
|
+
</Banner>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
if (
|
|
66
|
+
(catalog.loading && catalog.data === null) ||
|
|
67
|
+
(assignments.loading && assignments.data === null)
|
|
68
|
+
) {
|
|
69
|
+
return (
|
|
70
|
+
<Banner variant="loading" testId="folder-section-loading">
|
|
71
|
+
<Text>{t("folders.section.loading")}</Text>
|
|
72
|
+
</Banner>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const queryError = catalog.error ?? assignments.error;
|
|
76
|
+
if (queryError) {
|
|
77
|
+
return (
|
|
78
|
+
<Banner variant="error" testId="folder-section-error">
|
|
79
|
+
<Text>{t(queryError.i18nKey, queryError.i18nParams)}</Text>
|
|
80
|
+
</Banner>
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const folders = catalog.data?.rows ?? [];
|
|
85
|
+
const currentFolderId =
|
|
86
|
+
(assignments.data?.rows ?? []).find((r) => r.entityType === entityName)?.folderId ?? NO_FOLDER;
|
|
87
|
+
const options = [
|
|
88
|
+
{ value: NO_FOLDER, label: t("folders.section.none") },
|
|
89
|
+
...folders.map((f) => ({ value: f.id, label: folderPath(folders, f.id) })),
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
const refetch = async (): Promise<void> => {
|
|
93
|
+
await Promise.all([catalog.refetch(), assignments.refetch()]);
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const apply = async (write: () => Promise<boolean>): Promise<void> => {
|
|
97
|
+
setBusy(true);
|
|
98
|
+
setErrorKey(null);
|
|
99
|
+
try {
|
|
100
|
+
if (await write()) await refetch();
|
|
101
|
+
} finally {
|
|
102
|
+
setBusy(false);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const writeOk = async (type: string, payload: Record<string, unknown>): Promise<boolean> => {
|
|
107
|
+
const result = await dispatcher.write(type, payload);
|
|
108
|
+
if (!result.isSuccess) {
|
|
109
|
+
setErrorKey(result.error.i18nKey);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
return true;
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const onSelect = (next: string): void => {
|
|
116
|
+
if (next === currentFolderId) return;
|
|
117
|
+
void apply(() =>
|
|
118
|
+
next === NO_FOLDER
|
|
119
|
+
? writeOk(FoldersHandlers.clearFolder, { entityType: entityName, entityId })
|
|
120
|
+
: writeOk(FoldersHandlers.setFolder, { folderId: next, entityType: entityName, entityId }),
|
|
121
|
+
);
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
const createAndFile = (): void => {
|
|
125
|
+
const name = newName.trim();
|
|
126
|
+
if (name === "") return;
|
|
127
|
+
void apply(async () => {
|
|
128
|
+
const created = await dispatcher.write<{ id: string }>(FoldersHandlers.createFolder, {
|
|
129
|
+
name,
|
|
130
|
+
});
|
|
131
|
+
if (!created.isSuccess) {
|
|
132
|
+
setErrorKey(created.error.i18nKey);
|
|
133
|
+
return false;
|
|
134
|
+
}
|
|
135
|
+
if (
|
|
136
|
+
!(await writeOk(FoldersHandlers.setFolder, {
|
|
137
|
+
folderId: created.data.id,
|
|
138
|
+
entityType: entityName,
|
|
139
|
+
entityId,
|
|
140
|
+
}))
|
|
141
|
+
) {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
setNewName("");
|
|
145
|
+
return true;
|
|
146
|
+
});
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
return (
|
|
150
|
+
<div data-testid="folder-section" className="flex flex-col gap-4">
|
|
151
|
+
<Field id="folder-section-select" label={t("folders.section.label")}>
|
|
152
|
+
<Input
|
|
153
|
+
kind="combobox"
|
|
154
|
+
id="folder-section-select"
|
|
155
|
+
name="folder"
|
|
156
|
+
options={options}
|
|
157
|
+
value={currentFolderId}
|
|
158
|
+
onChange={onSelect}
|
|
159
|
+
disabled={busy}
|
|
160
|
+
placeholder={t("folders.section.placeholder")}
|
|
161
|
+
emptyText={t("folders.section.empty")}
|
|
162
|
+
/>
|
|
163
|
+
</Field>
|
|
164
|
+
|
|
165
|
+
{/* Inline create-row: das Ordner-Input wächst, der Anlegen-Button sitzt
|
|
166
|
+
rechts daneben. ponytail: separate Zeile, weil die Combobox keine
|
|
167
|
+
create-on-type-Affordance hat — fold-in, wenn renderer-web ein
|
|
168
|
+
onCreate-Prop bekommt. */}
|
|
169
|
+
<div className="flex items-end gap-2">
|
|
170
|
+
<div className="flex-1">
|
|
171
|
+
<Field id="folder-section-new" label={t("folders.section.newLabel")}>
|
|
172
|
+
<Input
|
|
173
|
+
kind="text"
|
|
174
|
+
id="folder-section-new"
|
|
175
|
+
name="newFolder"
|
|
176
|
+
value={newName}
|
|
177
|
+
onChange={setNewName}
|
|
178
|
+
/>
|
|
179
|
+
</Field>
|
|
180
|
+
</div>
|
|
181
|
+
<Button
|
|
182
|
+
variant="secondary"
|
|
183
|
+
disabled={busy || newName.trim() === ""}
|
|
184
|
+
onClick={() => createAndFile()}
|
|
185
|
+
testId="folder-section-create"
|
|
186
|
+
>
|
|
187
|
+
{busy ? t("folders.section.working") : t("folders.section.create")}
|
|
188
|
+
</Button>
|
|
189
|
+
</div>
|
|
190
|
+
|
|
191
|
+
{errorKey !== null && (
|
|
192
|
+
<Banner variant="error" testId="folder-section-action-error">
|
|
193
|
+
<Text>{t(errorKey)}</Text>
|
|
194
|
+
</Banner>
|
|
195
|
+
)}
|
|
196
|
+
</div>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Default translation bundle for the folders UI. foldersClient() hangs it into
|
|
3
|
+
// the LocaleProvider as a fallback bundle — apps override individual keys via
|
|
4
|
+
// foldersClient({ translations: { de: { ... } } }). Keys follow `folders.<area>.<slug>`.
|
|
5
|
+
|
|
6
|
+
import type { TranslationsByLocale } from "@cosmicdrift/kumiko-renderer";
|
|
7
|
+
|
|
8
|
+
export const defaultTranslations: TranslationsByLocale = {
|
|
9
|
+
de: {
|
|
10
|
+
"folders.section.createMode": "Speichere zuerst den Eintrag, um einen Ordner zu wählen.",
|
|
11
|
+
"folders.section.loading": "Lädt…",
|
|
12
|
+
"folders.section.label": "Ordner",
|
|
13
|
+
"folders.section.placeholder": "Ordner auswählen…",
|
|
14
|
+
"folders.section.empty": "Keine Ordner gefunden.",
|
|
15
|
+
"folders.section.none": "— Kein Ordner —",
|
|
16
|
+
"folders.section.newLabel": "Neuer Ordner",
|
|
17
|
+
"folders.section.create": "Ordner anlegen & ablegen",
|
|
18
|
+
"folders.section.working": "Speichert…",
|
|
19
|
+
|
|
20
|
+
"folders.manager.loading": "Lädt…",
|
|
21
|
+
"folders.manager.empty": "Noch keine Ordner.",
|
|
22
|
+
"folders.manager.newRoot": "Neuer Ordner",
|
|
23
|
+
"folders.manager.add": "Anlegen",
|
|
24
|
+
"folders.manager.addChild": "Unterordner",
|
|
25
|
+
"folders.manager.rename": "Umbenennen",
|
|
26
|
+
"folders.manager.delete": "Löschen",
|
|
27
|
+
"folders.manager.save": "Speichern",
|
|
28
|
+
"folders.manager.cancel": "Abbrechen",
|
|
29
|
+
"folders.manager.working": "Speichert…",
|
|
30
|
+
"folders.manager.deleteBlocked": "Erst Unterordner entfernen.",
|
|
31
|
+
},
|
|
32
|
+
en: {
|
|
33
|
+
"folders.section.createMode": "Save the entity first to pick a folder.",
|
|
34
|
+
"folders.section.loading": "Loading…",
|
|
35
|
+
"folders.section.label": "Folder",
|
|
36
|
+
"folders.section.placeholder": "Select a folder…",
|
|
37
|
+
"folders.section.empty": "No folders found.",
|
|
38
|
+
"folders.section.none": "— No folder —",
|
|
39
|
+
"folders.section.newLabel": "New folder",
|
|
40
|
+
"folders.section.create": "Create & file",
|
|
41
|
+
"folders.section.working": "Saving…",
|
|
42
|
+
|
|
43
|
+
"folders.manager.loading": "Loading…",
|
|
44
|
+
"folders.manager.empty": "No folders yet.",
|
|
45
|
+
"folders.manager.newRoot": "New folder",
|
|
46
|
+
"folders.manager.add": "Create",
|
|
47
|
+
"folders.manager.addChild": "Subfolder",
|
|
48
|
+
"folders.manager.rename": "Rename",
|
|
49
|
+
"folders.manager.delete": "Delete",
|
|
50
|
+
"folders.manager.save": "Save",
|
|
51
|
+
"folders.manager.cancel": "Cancel",
|
|
52
|
+
"folders.manager.working": "Saving…",
|
|
53
|
+
"folders.manager.deleteBlocked": "Remove subfolders first.",
|
|
54
|
+
},
|
|
55
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
export { FOLDER_SECTION_EXTENSION_NAME, FoldersHandlers, FoldersQueries } from "../constants";
|
|
3
|
+
export { foldersClient } from "./client-plugin";
|
|
4
|
+
export { FolderManager } from "./folder-manager";
|
|
5
|
+
export { FolderSection } from "./folder-section";
|
|
6
|
+
export { buildFolderTree, type FolderNode, type FolderRow, folderPath } from "./tree";
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// @runtime client
|
|
2
|
+
// Pure tree helpers shared by FolderManager (nested render) and FolderSection
|
|
3
|
+
// (path-labelled options). No React, no IO — folder rows in, structure out, so
|
|
4
|
+
// both can be unit-tested without a renderer harness.
|
|
5
|
+
|
|
6
|
+
export type FolderRow = {
|
|
7
|
+
readonly id: string;
|
|
8
|
+
readonly name: string;
|
|
9
|
+
readonly parentId: string | null;
|
|
10
|
+
readonly version: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type FolderNode = FolderRow & {
|
|
14
|
+
readonly children: readonly FolderNode[];
|
|
15
|
+
readonly depth: number;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function byName(a: FolderRow, b: FolderRow): number {
|
|
19
|
+
return a.name.localeCompare(b.name);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Roots (parentId null OR pointing at a row that no longer exists — a dangling
|
|
23
|
+
// parentId from a deleted parent stays visible at the top instead of vanishing)
|
|
24
|
+
// with children attached recursively, each level sorted by name.
|
|
25
|
+
export function buildFolderTree(rows: readonly FolderRow[]): readonly FolderNode[] {
|
|
26
|
+
const byParent = new Map<string | null, FolderRow[]>();
|
|
27
|
+
const ids = new Set(rows.map((r) => r.id));
|
|
28
|
+
for (const row of rows) {
|
|
29
|
+
const key = row.parentId !== null && ids.has(row.parentId) ? row.parentId : null;
|
|
30
|
+
const siblings = byParent.get(key);
|
|
31
|
+
if (siblings) siblings.push(row);
|
|
32
|
+
else byParent.set(key, [row]);
|
|
33
|
+
}
|
|
34
|
+
const build = (parentId: string | null, depth: number): readonly FolderNode[] =>
|
|
35
|
+
(byParent.get(parentId) ?? [])
|
|
36
|
+
.slice()
|
|
37
|
+
.sort(byName)
|
|
38
|
+
.map((row) => ({ ...row, depth, children: build(row.id, depth + 1) }));
|
|
39
|
+
return build(null, 0);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// "Immobilie Berlin / Person Müller" for a folder id, walking parentId up.
|
|
43
|
+
// Empty string if the id is unknown. The row-count cap stops a (currently
|
|
44
|
+
// impossible — no reparent yet) parent cycle from spinning forever.
|
|
45
|
+
export function folderPath(rows: readonly FolderRow[], id: string, separator = " / "): string {
|
|
46
|
+
const byId = new Map(rows.map((r) => [r.id, r]));
|
|
47
|
+
const names: string[] = [];
|
|
48
|
+
let current = byId.get(id);
|
|
49
|
+
for (let i = 0; current !== undefined && i <= rows.length; i += 1) {
|
|
50
|
+
names.unshift(current.name);
|
|
51
|
+
current = current.parentId !== null ? byId.get(current.parentId) : undefined;
|
|
52
|
+
}
|
|
53
|
+
return names.join(separator);
|
|
54
|
+
}
|