@cosmicdrift/kumiko-bundled-features 0.91.0 → 0.93.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.93.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>",
|
|
@@ -90,11 +90,11 @@
|
|
|
90
90
|
"./step-dispatcher": "./src/step-dispatcher/index.ts"
|
|
91
91
|
},
|
|
92
92
|
"dependencies": {
|
|
93
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
94
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
95
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
96
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
93
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.93.0",
|
|
94
|
+
"@cosmicdrift/kumiko-framework": "0.93.0",
|
|
95
|
+
"@cosmicdrift/kumiko-headless": "0.93.0",
|
|
96
|
+
"@cosmicdrift/kumiko-renderer": "0.93.0",
|
|
97
|
+
"@cosmicdrift/kumiko-renderer-web": "0.93.0",
|
|
98
98
|
"@mollie/api-client": "^4.5.0",
|
|
99
99
|
"@node-rs/argon2": "^2.0.2",
|
|
100
100
|
"@types/nodemailer": "^8.0.0",
|
|
@@ -0,0 +1,168 @@
|
|
|
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 { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
9
|
+
import type { ReactNode } from "react";
|
|
10
|
+
import { FoldersHandlers, FoldersQueries } from "../../constants";
|
|
11
|
+
import { type FolderFiling, FolderManager } from "../folder-manager";
|
|
12
|
+
import { defaultTranslations } from "../i18n";
|
|
13
|
+
|
|
14
|
+
type FolderRow = { id: string; name: string; parentId: string | null; version: number };
|
|
15
|
+
|
|
16
|
+
let folderRows: readonly FolderRow[] = [];
|
|
17
|
+
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
folderRows = [];
|
|
20
|
+
dispatchSpy.mockClear();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
const dispatchSpy = mock(async () => ({ isSuccess: true, data: undefined }));
|
|
24
|
+
|
|
25
|
+
const useQuerySpy = mock((type: string) => ({
|
|
26
|
+
data: type === FoldersQueries.folderList ? { rows: folderRows } : { rows: [] },
|
|
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
|
+
useDispatcher: mock(() => ({ write: dispatchSpy, query: mock(), batch: mock() })),
|
|
36
|
+
useQuery: useQuerySpy,
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
function Wrapper({ children }: { readonly children: ReactNode }): ReactNode {
|
|
40
|
+
return (
|
|
41
|
+
<LocaleProvider resolver={createStaticLocaleResolver()} fallbackBundles={[defaultTranslations]}>
|
|
42
|
+
<PrimitivesProvider value={defaultPrimitives}>{children}</PrimitivesProvider>
|
|
43
|
+
</LocaleProvider>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const filingWith = (onReassigned: () => void): FolderFiling => ({
|
|
48
|
+
entityType: "credit",
|
|
49
|
+
leavesByFolder: new Map([["f1", [{ id: "c-1", label: "Credit 1" }]]]),
|
|
50
|
+
unfiled: [{ id: "c-2", label: "Credit 2" }],
|
|
51
|
+
unfiledLabel: "Unfiled",
|
|
52
|
+
onReassigned,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const dropLeaf = (targetTestId: string, entityId: string): void => {
|
|
56
|
+
fireEvent.drop(screen.getByTestId(targetTestId), {
|
|
57
|
+
dataTransfer: { getData: () => entityId },
|
|
58
|
+
});
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
describe("FolderManager filing mode", () => {
|
|
62
|
+
test("renders filed leaves under their folder and the unfiled bucket", () => {
|
|
63
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
64
|
+
render(
|
|
65
|
+
<Wrapper>
|
|
66
|
+
<FolderManager filing={filingWith(() => {})} />
|
|
67
|
+
</Wrapper>,
|
|
68
|
+
);
|
|
69
|
+
expect(screen.getByTestId("folder-leaf-c-1")).toBeTruthy();
|
|
70
|
+
expect(screen.getByTestId("folder-node-unfiled")).toBeTruthy();
|
|
71
|
+
expect(screen.getByTestId("folder-leaf-c-2")).toBeTruthy();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("dropping a leaf on another folder dispatches set-folder + refetches the host", async () => {
|
|
75
|
+
folderRows = [
|
|
76
|
+
{ id: "f1", name: "A", parentId: null, version: 1 },
|
|
77
|
+
{ id: "f2", name: "B", parentId: null, version: 1 },
|
|
78
|
+
];
|
|
79
|
+
const onReassigned = mock(() => {});
|
|
80
|
+
render(
|
|
81
|
+
<Wrapper>
|
|
82
|
+
<FolderManager filing={filingWith(onReassigned)} />
|
|
83
|
+
</Wrapper>,
|
|
84
|
+
);
|
|
85
|
+
dropLeaf("folder-node-f2", "c-1");
|
|
86
|
+
await waitFor(() =>
|
|
87
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.setFolder, {
|
|
88
|
+
folderId: "f2",
|
|
89
|
+
entityType: "credit",
|
|
90
|
+
entityId: "c-1",
|
|
91
|
+
}),
|
|
92
|
+
);
|
|
93
|
+
await waitFor(() => expect(onReassigned).toHaveBeenCalled());
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("dropping a leaf on the unfiled bucket dispatches clear-folder", async () => {
|
|
97
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
98
|
+
render(
|
|
99
|
+
<Wrapper>
|
|
100
|
+
<FolderManager filing={filingWith(() => {})} />
|
|
101
|
+
</Wrapper>,
|
|
102
|
+
);
|
|
103
|
+
dropLeaf("folder-node-unfiled", "c-1");
|
|
104
|
+
await waitFor(() =>
|
|
105
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.clearFolder, {
|
|
106
|
+
entityType: "credit",
|
|
107
|
+
entityId: "c-1",
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
test("dropping a leaf on the folder it already lives in is a no-op (no write)", async () => {
|
|
113
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
114
|
+
render(
|
|
115
|
+
<Wrapper>
|
|
116
|
+
<FolderManager filing={filingWith(() => {})} />
|
|
117
|
+
</Wrapper>,
|
|
118
|
+
);
|
|
119
|
+
dropLeaf("folder-node-f1", "c-1");
|
|
120
|
+
// give any (erroneous) async write a tick to land
|
|
121
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
122
|
+
expect(dispatchSpy).not.toHaveBeenCalled();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("without filing the manager renders no leaves or bucket (backward compatible)", () => {
|
|
126
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
127
|
+
render(
|
|
128
|
+
<Wrapper>
|
|
129
|
+
<FolderManager />
|
|
130
|
+
</Wrapper>,
|
|
131
|
+
);
|
|
132
|
+
expect(screen.getByTestId("folder-node-f1")).toBeTruthy();
|
|
133
|
+
expect(screen.queryByTestId("folder-node-unfiled")).toBeNull();
|
|
134
|
+
expect(screen.queryByTestId("folder-leaf-c-1")).toBeNull();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("delete is confirm-gated; confirming dispatches delete-folder", async () => {
|
|
138
|
+
folderRows = [{ id: "f1", name: "A", parentId: null, version: 1 }];
|
|
139
|
+
render(
|
|
140
|
+
<Wrapper>
|
|
141
|
+
<FolderManager />
|
|
142
|
+
</Wrapper>,
|
|
143
|
+
);
|
|
144
|
+
fireEvent.click(screen.getByTestId("folder-delete-f1"));
|
|
145
|
+
expect(dispatchSpy).not.toHaveBeenCalled(); // no write before confirm
|
|
146
|
+
fireEvent.click(await screen.findByTestId("folder-manager-delete-dialog-confirm"));
|
|
147
|
+
await waitFor(() =>
|
|
148
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.deleteFolder, { id: "f1" }),
|
|
149
|
+
);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("the in-tree new-folder row opens a draft; submitting (Enter) creates a root folder", async () => {
|
|
153
|
+
folderRows = [];
|
|
154
|
+
render(
|
|
155
|
+
<Wrapper>
|
|
156
|
+
<FolderManager />
|
|
157
|
+
</Wrapper>,
|
|
158
|
+
);
|
|
159
|
+
fireEvent.click(screen.getByTestId("folder-manager-new-root"));
|
|
160
|
+
fireEvent.change(document.getElementById("folder-manager-draft") as HTMLInputElement, {
|
|
161
|
+
target: { value: "Inbox" },
|
|
162
|
+
});
|
|
163
|
+
fireEvent.submit(screen.getByTestId("folder-manager-draft"));
|
|
164
|
+
await waitFor(() =>
|
|
165
|
+
expect(dispatchSpy).toHaveBeenCalledWith(FoldersHandlers.createFolder, { name: "Inbox" }),
|
|
166
|
+
);
|
|
167
|
+
});
|
|
168
|
+
});
|
|
@@ -5,21 +5,27 @@
|
|
|
5
5
|
// rolled-up Restschuld/Rate) next to each node — the manager knows nothing about
|
|
6
6
|
// what's filed in a folder, only the folder tree itself.
|
|
7
7
|
//
|
|
8
|
+
// Optional `filing` mode: when a host hands in its entities (grouped by folder +
|
|
9
|
+
// an unfiled bucket) the manager interleaves them as draggable leaf rows and
|
|
10
|
+
// becomes a full filing tree — drag a leaf onto a folder to file it (set-folder),
|
|
11
|
+
// onto the unfiled bucket to unfile it (clear-folder). The host owns the entity
|
|
12
|
+
// data + stats; the manager owns the folder writes AND the reassignment writes,
|
|
13
|
+
// then refetches its own catalog and calls the host's onReassigned to refresh the
|
|
14
|
+
// host's assignment-derived data. ponytail: native HTML5 DnD, desktop-only;
|
|
15
|
+
// pointer-based (dnd-kit) only if touch filing is ever needed — keyboard filing
|
|
16
|
+
// stays available via <FolderSection>.
|
|
17
|
+
//
|
|
8
18
|
// Finder/Explorer-style: the tree is flattened to its visible rows in DFS order
|
|
9
19
|
// and rendered as full-width rows with (a) alternating zebra striping, (b) one
|
|
10
20
|
// vertical guide line per ancestor depth, and (c) a disclosure chevron + folder
|
|
11
|
-
// icon.
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// of depth.
|
|
21
|
+
// icon. The chevron+name toggle a node; expansion state persists in localStorage
|
|
22
|
+
// so a reload doesn't re-expand everything. Create/rename happen inline (Enter
|
|
23
|
+
// saves); delete asks for confirmation; root folders are added via the in-tree
|
|
24
|
+
// "+ new folder" row (no floating toolbar button). Icons are lucide-react.
|
|
16
25
|
//
|
|
17
26
|
// NOTE on Tailwind: a host's Tailwind v4 build must `@source`-scan this package's
|
|
18
27
|
// src (node_modules is ignored by default) or these classes never compile. money-
|
|
19
28
|
// 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
29
|
|
|
24
30
|
import {
|
|
25
31
|
useDispatcher,
|
|
@@ -27,21 +33,51 @@ import {
|
|
|
27
33
|
useQuery,
|
|
28
34
|
useTranslation,
|
|
29
35
|
} from "@cosmicdrift/kumiko-renderer";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
ChevronRight,
|
|
33
|
-
Folder,
|
|
34
|
-
type LucideIcon,
|
|
35
|
-
Pencil,
|
|
36
|
-
Plus,
|
|
37
|
-
Trash2,
|
|
38
|
-
} from "lucide-react";
|
|
39
|
-
import { type ReactNode, useState } from "react";
|
|
36
|
+
import { ChevronRight, File, Folder, type LucideIcon, Pencil, Plus, Trash2 } from "lucide-react";
|
|
37
|
+
import { type DragEvent, type ReactNode, useEffect, useState } from "react";
|
|
40
38
|
import { FoldersHandlers, FoldersQueries } from "../constants";
|
|
41
39
|
import { buildFolderTree, type FolderNode, type FolderRow } from "./tree";
|
|
42
40
|
|
|
43
41
|
type FolderListResponse = { readonly rows: readonly FolderRow[] };
|
|
44
42
|
|
|
43
|
+
// One filed entity, rendered as a draggable leaf under its folder. The host maps
|
|
44
|
+
// its rows into these (label + an optional trailing slot for a KPI/amount).
|
|
45
|
+
export type FolderLeaf = {
|
|
46
|
+
readonly id: string;
|
|
47
|
+
readonly label: string;
|
|
48
|
+
readonly trailing?: ReactNode;
|
|
49
|
+
readonly onOpen?: () => void;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// Opt-in filing binding. Host computes the grouping (it already holds the
|
|
53
|
+
// assignments for its stats); the manager renders + drives the set/clear-folder
|
|
54
|
+
// writes and tells the host to refetch via onReassigned.
|
|
55
|
+
export type FolderFiling = {
|
|
56
|
+
readonly entityType: string;
|
|
57
|
+
readonly leavesByFolder: ReadonlyMap<string, readonly FolderLeaf[]>;
|
|
58
|
+
readonly unfiled: readonly FolderLeaf[];
|
|
59
|
+
readonly unfiledLabel: string;
|
|
60
|
+
readonly unfiledMeta?: ReactNode;
|
|
61
|
+
readonly leafIcon?: LucideIcon;
|
|
62
|
+
readonly onReassigned: () => Promise<void> | void;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const UNFILED = "__unfiled__";
|
|
66
|
+
const DRAG_MIME = "text/plain";
|
|
67
|
+
// ponytail: one global key — folder expansion is low-stakes UI state shared
|
|
68
|
+
// across every folder tree in the app; a per-host key only if trees ever diverge.
|
|
69
|
+
const COLLAPSED_KEY = "kumiko:folders:collapsed";
|
|
70
|
+
|
|
71
|
+
const loadCollapsed = (): ReadonlySet<string> => {
|
|
72
|
+
if (typeof window === "undefined") return new Set();
|
|
73
|
+
try {
|
|
74
|
+
const raw = window.localStorage.getItem(COLLAPSED_KEY);
|
|
75
|
+
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
|
76
|
+
} catch {
|
|
77
|
+
return new Set();
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
45
81
|
type Pending =
|
|
46
82
|
| { readonly mode: "create"; readonly parentId: string | null }
|
|
47
83
|
| { readonly mode: "rename"; readonly id: string; readonly version: number }
|
|
@@ -49,20 +85,35 @@ type Pending =
|
|
|
49
85
|
|
|
50
86
|
export function FolderManager({
|
|
51
87
|
renderMeta,
|
|
88
|
+
filing,
|
|
52
89
|
}: {
|
|
53
90
|
// Optional per-folder slot (right side of each row). The host owns the data;
|
|
54
91
|
// the manager just gives it a place to render.
|
|
55
92
|
readonly renderMeta?: (folder: FolderRow) => ReactNode;
|
|
93
|
+
// Optional filing mode: interleave the host's draggable entities + DnD.
|
|
94
|
+
readonly filing?: FolderFiling;
|
|
56
95
|
}): ReactNode {
|
|
57
|
-
const { Banner, Button, Input, Text } = usePrimitives();
|
|
96
|
+
const { Banner, Button, Dialog, Input, Text } = usePrimitives();
|
|
58
97
|
const t = useTranslation();
|
|
59
98
|
const dispatcher = useDispatcher();
|
|
60
99
|
const catalog = useQuery<FolderListResponse>(FoldersQueries.folderList, {});
|
|
61
100
|
const [pending, setPending] = useState<Pending>(null);
|
|
62
101
|
const [draftName, setDraftName] = useState("");
|
|
63
|
-
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(
|
|
102
|
+
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(loadCollapsed);
|
|
64
103
|
const [busy, setBusy] = useState(false);
|
|
65
104
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
|
105
|
+
const [dragOverKey, setDragOverKey] = useState<string | null>(null);
|
|
106
|
+
const [pendingDelete, setPendingDelete] = useState<FolderNode | null>(null);
|
|
107
|
+
|
|
108
|
+
// Persist expand/collapse so a reload (F5) doesn't re-expand everything.
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (typeof window === "undefined") return;
|
|
111
|
+
try {
|
|
112
|
+
window.localStorage.setItem(COLLAPSED_KEY, JSON.stringify([...collapsed]));
|
|
113
|
+
} catch {
|
|
114
|
+
// storage unavailable (private mode / quota) — expansion just won't persist
|
|
115
|
+
}
|
|
116
|
+
}, [collapsed]);
|
|
66
117
|
|
|
67
118
|
if (catalog.loading && catalog.data === null) {
|
|
68
119
|
return (
|
|
@@ -82,6 +133,14 @@ export function FolderManager({
|
|
|
82
133
|
const rows = catalog.data?.rows ?? [];
|
|
83
134
|
const tree = buildFolderTree(rows);
|
|
84
135
|
|
|
136
|
+
// entityId → current folder (null = unfiled) for the drop no-op guard.
|
|
137
|
+
const currentFolderByEntity = new Map<string, string | null>();
|
|
138
|
+
if (filing !== undefined) {
|
|
139
|
+
for (const [folderId, leaves] of filing.leavesByFolder)
|
|
140
|
+
for (const leaf of leaves) currentFolderByEntity.set(leaf.id, folderId);
|
|
141
|
+
for (const leaf of filing.unfiled) currentFolderByEntity.set(leaf.id, null);
|
|
142
|
+
}
|
|
143
|
+
|
|
85
144
|
const toggleCollapse = (id: string): void =>
|
|
86
145
|
setCollapsed((prev) => {
|
|
87
146
|
const next = new Set(prev);
|
|
@@ -144,16 +203,68 @@ export function FolderManager({
|
|
|
144
203
|
);
|
|
145
204
|
};
|
|
146
205
|
|
|
147
|
-
|
|
206
|
+
// Delete goes through a confirm dialog — a folder can hold filed entries that
|
|
207
|
+
// fall back to "unfiled" when it's gone. Child folders still block outright.
|
|
208
|
+
const requestDelete = (node: FolderNode): void => {
|
|
148
209
|
if (node.children.length > 0) {
|
|
149
210
|
setErrorKey("folders.manager.deleteBlocked");
|
|
150
211
|
return;
|
|
151
212
|
}
|
|
152
|
-
|
|
213
|
+
setErrorKey(null);
|
|
214
|
+
setPendingDelete(node);
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
const confirmDelete = async (): Promise<void> => {
|
|
218
|
+
const node = pendingDelete;
|
|
219
|
+
setPendingDelete(null);
|
|
220
|
+
if (node === null) return;
|
|
221
|
+
await apply(() => writeOk(FoldersHandlers.deleteFolder, { id: node.id }));
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Drop a leaf into a folder (set-folder) or the unfiled bucket (clear-folder).
|
|
225
|
+
// No-op if it already lives there (both handlers are idempotent, but a re-set
|
|
226
|
+
// would burn a redundant event).
|
|
227
|
+
const reassign = async (entityId: string, folderId: string | null): Promise<void> => {
|
|
228
|
+
if (filing === undefined) return;
|
|
229
|
+
if ((currentFolderByEntity.get(entityId) ?? null) === folderId) return;
|
|
230
|
+
setErrorKey(null);
|
|
231
|
+
const ok =
|
|
232
|
+
folderId === null
|
|
233
|
+
? await writeOk(FoldersHandlers.clearFolder, { entityType: filing.entityType, entityId })
|
|
234
|
+
: await writeOk(FoldersHandlers.setFolder, {
|
|
235
|
+
folderId,
|
|
236
|
+
entityType: filing.entityType,
|
|
237
|
+
entityId,
|
|
238
|
+
});
|
|
239
|
+
if (ok) {
|
|
240
|
+
await catalog.refetch();
|
|
241
|
+
await filing.onReassigned();
|
|
242
|
+
}
|
|
153
243
|
};
|
|
154
244
|
|
|
155
|
-
//
|
|
156
|
-
//
|
|
245
|
+
// Drop-target handlers, attached only in filing mode (so plain folder
|
|
246
|
+
// management keeps non-interactive rows).
|
|
247
|
+
const dropProps = (key: string, folderId: string | null) =>
|
|
248
|
+
filing === undefined
|
|
249
|
+
? {}
|
|
250
|
+
: {
|
|
251
|
+
onDragOver: (e: DragEvent<HTMLDivElement>) => {
|
|
252
|
+
e.preventDefault();
|
|
253
|
+
e.dataTransfer.dropEffect = "move";
|
|
254
|
+
setDragOverKey(key);
|
|
255
|
+
},
|
|
256
|
+
onDragLeave: () => setDragOverKey((cur) => (cur === key ? null : cur)),
|
|
257
|
+
onDrop: (e: DragEvent<HTMLDivElement>) => {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
setDragOverKey(null);
|
|
260
|
+
const id = e.dataTransfer.getData(DRAG_MIME);
|
|
261
|
+
if (id !== "") void reassign(id, folderId);
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// One vertical guide line per ancestor depth, centered in a chevron-width
|
|
266
|
+
// column; self-stretch fills the padding-free min-h-9 row edge-to-edge, so
|
|
267
|
+
// consecutive rows' lines join into continuous, gapless rails.
|
|
157
268
|
const rails = (depth: number): ReactNode =>
|
|
158
269
|
Array.from({ length: depth }, (_, i) => (
|
|
159
270
|
// biome-ignore lint/suspicious/noArrayIndexKey: positional rail, no identity
|
|
@@ -162,10 +273,10 @@ export function FolderManager({
|
|
|
162
273
|
</span>
|
|
163
274
|
));
|
|
164
275
|
|
|
165
|
-
const rowClass = (stripe: boolean): string =>
|
|
166
|
-
`flex items-center gap-1.5 px-2
|
|
276
|
+
const rowClass = (stripe: boolean, dropActive: boolean): string =>
|
|
277
|
+
`flex min-h-9 items-center gap-1.5 px-2 transition-colors hover:bg-muted/60 ${
|
|
167
278
|
stripe ? "bg-muted/40" : ""
|
|
168
|
-
}`;
|
|
279
|
+
} ${dropActive ? "ring-1 ring-inset ring-primary/40" : ""}`;
|
|
169
280
|
|
|
170
281
|
const actionButton = (
|
|
171
282
|
label: string,
|
|
@@ -182,7 +293,7 @@ export function FolderManager({
|
|
|
182
293
|
aria-label={label}
|
|
183
294
|
title={label}
|
|
184
295
|
data-testid={testId}
|
|
185
|
-
className={`rounded p-1 text-muted-foreground transition-colors hover:bg-background disabled:opacity-40 ${
|
|
296
|
+
className={`cursor-pointer rounded p-1 text-muted-foreground transition-colors hover:bg-background disabled:opacity-40 ${
|
|
186
297
|
danger ? "hover:text-destructive" : "hover:text-foreground"
|
|
187
298
|
}`}
|
|
188
299
|
>
|
|
@@ -190,8 +301,52 @@ export function FolderManager({
|
|
|
190
301
|
</button>
|
|
191
302
|
);
|
|
192
303
|
|
|
304
|
+
// Chevron + folder icon + name = one click target that toggles (cursor-pointer).
|
|
305
|
+
// A non-expandable folder renders the same layout without the toggle affordance.
|
|
306
|
+
const toggleArea = (
|
|
307
|
+
id: string,
|
|
308
|
+
label: string,
|
|
309
|
+
expanded: boolean,
|
|
310
|
+
expandable: boolean,
|
|
311
|
+
): ReactNode =>
|
|
312
|
+
expandable ? (
|
|
313
|
+
// kumiko-lint-ignore primitives-discipline: dense Finder-row toggle (chevron + name); the Button primitive would break the single-row layout
|
|
314
|
+
<button
|
|
315
|
+
type="button"
|
|
316
|
+
aria-expanded={expanded}
|
|
317
|
+
onClick={() => toggleCollapse(id)}
|
|
318
|
+
data-testid={`folder-toggle-${id}`}
|
|
319
|
+
className="flex min-w-0 flex-1 cursor-pointer items-center gap-1.5 text-left"
|
|
320
|
+
>
|
|
321
|
+
<ChevronRight
|
|
322
|
+
size={16}
|
|
323
|
+
aria-hidden="true"
|
|
324
|
+
className={`shrink-0 text-muted-foreground transition-transform ${
|
|
325
|
+
expanded ? "rotate-90" : ""
|
|
326
|
+
}`}
|
|
327
|
+
/>
|
|
328
|
+
<Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
|
|
329
|
+
<span className="flex-1 truncate font-medium">{label}</span>
|
|
330
|
+
</button>
|
|
331
|
+
) : (
|
|
332
|
+
<div className="flex min-w-0 flex-1 items-center gap-1.5">
|
|
333
|
+
<span className="w-4 shrink-0" />
|
|
334
|
+
<Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground" />
|
|
335
|
+
<span className="flex-1 truncate font-medium">{label}</span>
|
|
336
|
+
</div>
|
|
337
|
+
);
|
|
338
|
+
|
|
193
339
|
const draftRow = (depth: number, key: string, stripe: boolean): ReactNode => (
|
|
194
|
-
|
|
340
|
+
// kumiko-lint-ignore primitives-discipline: native form only to capture Enter-to-submit on the inline draft field
|
|
341
|
+
<form
|
|
342
|
+
key={key}
|
|
343
|
+
className={rowClass(stripe, false)}
|
|
344
|
+
data-testid="folder-manager-draft"
|
|
345
|
+
onSubmit={(e) => {
|
|
346
|
+
e.preventDefault();
|
|
347
|
+
saveDraft();
|
|
348
|
+
}}
|
|
349
|
+
>
|
|
195
350
|
{rails(depth)}
|
|
196
351
|
<Folder size={16} aria-hidden="true" className="shrink-0 text-muted-foreground/50" />
|
|
197
352
|
<div className="flex-1">
|
|
@@ -215,35 +370,47 @@ export function FolderManager({
|
|
|
215
370
|
<Button variant="secondary" disabled={busy} onClick={() => setPending(null)}>
|
|
216
371
|
{t("folders.manager.cancel")}
|
|
217
372
|
</Button>
|
|
218
|
-
</
|
|
373
|
+
</form>
|
|
219
374
|
);
|
|
220
375
|
|
|
376
|
+
const leafRow = (leaf: FolderLeaf, depth: number, stripe: boolean): ReactNode => {
|
|
377
|
+
const LeafIcon = filing?.leafIcon ?? File;
|
|
378
|
+
return (
|
|
379
|
+
// kumiko-lint-ignore primitives-discipline: draggable Finder-style leaf row; the Button primitive can't be a dense, full-row drag source
|
|
380
|
+
<button
|
|
381
|
+
key={`leaf-${leaf.id}`}
|
|
382
|
+
type="button"
|
|
383
|
+
draggable
|
|
384
|
+
onDragStart={(e) => {
|
|
385
|
+
e.dataTransfer.setData(DRAG_MIME, leaf.id);
|
|
386
|
+
e.dataTransfer.effectAllowed = "move";
|
|
387
|
+
}}
|
|
388
|
+
onClick={leaf.onOpen}
|
|
389
|
+
className={`${rowClass(stripe, false)} w-full cursor-pointer text-left`}
|
|
390
|
+
data-testid={`folder-leaf-${leaf.id}`}
|
|
391
|
+
>
|
|
392
|
+
{rails(depth)}
|
|
393
|
+
<span className="w-5 shrink-0" />
|
|
394
|
+
<LeafIcon size={16} aria-hidden="true" className="shrink-0 text-muted-foreground/70" />
|
|
395
|
+
<span className="flex-1 truncate">{leaf.label}</span>
|
|
396
|
+
{leaf.trailing}
|
|
397
|
+
</button>
|
|
398
|
+
);
|
|
399
|
+
};
|
|
400
|
+
|
|
221
401
|
const folderRow = (node: FolderNode, depth: number, stripe: boolean): ReactNode => {
|
|
222
|
-
const
|
|
223
|
-
const
|
|
402
|
+
const leaves = filing?.leavesByFolder.get(node.id) ?? [];
|
|
403
|
+
const expandable = node.children.length > 0 || leaves.length > 0;
|
|
404
|
+
const expanded = expandable && !collapsed.has(node.id);
|
|
224
405
|
return (
|
|
225
|
-
<div
|
|
406
|
+
<div
|
|
407
|
+
key={node.id}
|
|
408
|
+
data-testid={`folder-node-${node.id}`}
|
|
409
|
+
className={rowClass(stripe, dragOverKey === node.id)}
|
|
410
|
+
{...dropProps(node.id, node.id)}
|
|
411
|
+
>
|
|
226
412
|
{rails(depth)}
|
|
227
|
-
{
|
|
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>
|
|
413
|
+
{toggleArea(node.id, node.name, expanded, expandable)}
|
|
247
414
|
{renderMeta?.(node)}
|
|
248
415
|
<div className="flex items-center gap-0.5">
|
|
249
416
|
{actionButton(t("folders.manager.addChild"), `folder-add-child-${node.id}`, Plus, () =>
|
|
@@ -256,7 +423,7 @@ export function FolderManager({
|
|
|
256
423
|
t("folders.manager.delete"),
|
|
257
424
|
`folder-delete-${node.id}`,
|
|
258
425
|
Trash2,
|
|
259
|
-
() =>
|
|
426
|
+
() => requestDelete(node),
|
|
260
427
|
true,
|
|
261
428
|
)}
|
|
262
429
|
</div>
|
|
@@ -264,9 +431,43 @@ export function FolderManager({
|
|
|
264
431
|
);
|
|
265
432
|
};
|
|
266
433
|
|
|
434
|
+
const bucketRow = (stripe: boolean): ReactNode => {
|
|
435
|
+
if (filing === undefined) return null;
|
|
436
|
+
const expanded = !collapsed.has(UNFILED);
|
|
437
|
+
return (
|
|
438
|
+
<div
|
|
439
|
+
key={UNFILED}
|
|
440
|
+
data-testid="folder-node-unfiled"
|
|
441
|
+
className={rowClass(stripe, dragOverKey === UNFILED)}
|
|
442
|
+
{...dropProps(UNFILED, null)}
|
|
443
|
+
>
|
|
444
|
+
{toggleArea(UNFILED, filing.unfiledLabel, expanded, true)}
|
|
445
|
+
{filing.unfiledMeta}
|
|
446
|
+
</div>
|
|
447
|
+
);
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
// Subtle in-tree row to add a root folder (replaces the old floating toolbar
|
|
451
|
+
// button, which caused an ugly header jump in hosts with their own actions).
|
|
452
|
+
const newRootRow = (): ReactNode => (
|
|
453
|
+
// kumiko-lint-ignore primitives-discipline: dense Finder-row action (add root folder); the Button primitive would break the row layout
|
|
454
|
+
<button
|
|
455
|
+
type="button"
|
|
456
|
+
disabled={busy}
|
|
457
|
+
onClick={() => openCreate(null)}
|
|
458
|
+
data-testid="folder-manager-new-root"
|
|
459
|
+
className="flex min-h-9 w-full cursor-pointer items-center gap-1.5 px-2 text-left text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground disabled:opacity-40"
|
|
460
|
+
>
|
|
461
|
+
<span className="w-5 shrink-0" />
|
|
462
|
+
<Plus size={16} aria-hidden="true" className="shrink-0" />
|
|
463
|
+
<span className="font-medium">{t("folders.manager.newRoot")}</span>
|
|
464
|
+
</button>
|
|
465
|
+
);
|
|
466
|
+
|
|
267
467
|
// Flatten the visible tree (DFS) into ordered rows so the zebra index + guide
|
|
268
468
|
// 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.
|
|
469
|
+
// a pending create appends a draft row at the end of its parent's children. In
|
|
470
|
+
// filing mode a folder's leaf rows render before its subfolders.
|
|
270
471
|
const out: ReactNode[] = [];
|
|
271
472
|
let idx = 0;
|
|
272
473
|
const stripeNext = (): boolean => {
|
|
@@ -282,42 +483,54 @@ export function FolderManager({
|
|
|
282
483
|
? draftRow(depth, `rename-${node.id}`, stripeNext())
|
|
283
484
|
: folderRow(node, depth, stripeNext()),
|
|
284
485
|
);
|
|
285
|
-
|
|
486
|
+
const leaves = filing?.leavesByFolder.get(node.id) ?? [];
|
|
487
|
+
const expandable = node.children.length > 0 || leaves.length > 0;
|
|
488
|
+
if (expandable && !collapsed.has(node.id)) {
|
|
489
|
+
for (const leaf of leaves) out.push(leafRow(leaf, depth + 1, stripeNext()));
|
|
490
|
+
if (node.children.length > 0) walk(node.children, depth + 1);
|
|
491
|
+
}
|
|
286
492
|
if (pending?.mode === "create" && pending.parentId === node.id)
|
|
287
493
|
out.push(draftRow(depth + 1, `create-under-${node.id}`, stripeNext()));
|
|
288
494
|
}
|
|
289
495
|
};
|
|
290
496
|
walk(tree, 0);
|
|
291
497
|
|
|
498
|
+
const hasUnfiled = filing !== undefined && filing.unfiled.length > 0;
|
|
499
|
+
if (hasUnfiled) {
|
|
500
|
+
out.push(bucketRow(stripeNext()));
|
|
501
|
+
if (!collapsed.has(UNFILED))
|
|
502
|
+
for (const leaf of filing.unfiled) out.push(leafRow(leaf, 1, stripeNext()));
|
|
503
|
+
}
|
|
504
|
+
|
|
292
505
|
const creatingRoot = pending?.mode === "create" && pending.parentId === null;
|
|
293
506
|
if (creatingRoot) out.unshift(draftRow(0, "create-root", false));
|
|
294
507
|
|
|
295
508
|
return (
|
|
296
509
|
<div data-testid="folder-manager" className="flex flex-col gap-2">
|
|
297
|
-
<div className="
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
disabled={busy || creatingRoot}
|
|
301
|
-
onClick={() => openCreate(null)}
|
|
302
|
-
testId="folder-manager-new-root"
|
|
303
|
-
>
|
|
304
|
-
{t("folders.manager.newRoot")}
|
|
305
|
-
</Button>
|
|
510
|
+
<div className="overflow-hidden rounded-md border">
|
|
511
|
+
{out}
|
|
512
|
+
{!creatingRoot && newRootRow()}
|
|
306
513
|
</div>
|
|
307
514
|
|
|
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
515
|
{errorKey !== null && (
|
|
317
516
|
<Banner variant="error" testId="folder-manager-action-error">
|
|
318
517
|
<Text>{t(errorKey)}</Text>
|
|
319
518
|
</Banner>
|
|
320
519
|
)}
|
|
520
|
+
|
|
521
|
+
<Dialog
|
|
522
|
+
open={pendingDelete !== null}
|
|
523
|
+
onOpenChange={(o) => {
|
|
524
|
+
if (!o) setPendingDelete(null);
|
|
525
|
+
}}
|
|
526
|
+
title={t("folders.manager.deleteConfirmTitle")}
|
|
527
|
+
description={t("folders.manager.deleteConfirmBody")}
|
|
528
|
+
confirmLabel={t("folders.manager.delete")}
|
|
529
|
+
cancelLabel={t("folders.manager.cancel")}
|
|
530
|
+
variant="danger"
|
|
531
|
+
onConfirm={confirmDelete}
|
|
532
|
+
testId="folder-manager-delete-dialog"
|
|
533
|
+
/>
|
|
321
534
|
</div>
|
|
322
535
|
);
|
|
323
536
|
}
|
package/src/folders/web/i18n.ts
CHANGED
|
@@ -28,6 +28,9 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
28
28
|
"folders.manager.cancel": "Abbrechen",
|
|
29
29
|
"folders.manager.working": "Speichert…",
|
|
30
30
|
"folders.manager.deleteBlocked": "Erst Unterordner entfernen.",
|
|
31
|
+
"folders.manager.deleteConfirmTitle": "Ordner löschen?",
|
|
32
|
+
"folders.manager.deleteConfirmBody":
|
|
33
|
+
"Der Ordner wird entfernt. Abgelegte Einträge wandern zurück zu „Ohne Ordner“.",
|
|
31
34
|
},
|
|
32
35
|
en: {
|
|
33
36
|
"folders.section.createMode": "Save the entity first to pick a folder.",
|
|
@@ -51,5 +54,8 @@ export const defaultTranslations: TranslationsByLocale = {
|
|
|
51
54
|
"folders.manager.cancel": "Cancel",
|
|
52
55
|
"folders.manager.working": "Saving…",
|
|
53
56
|
"folders.manager.deleteBlocked": "Remove subfolders first.",
|
|
57
|
+
"folders.manager.deleteConfirmTitle": "Delete folder?",
|
|
58
|
+
"folders.manager.deleteConfirmBody":
|
|
59
|
+
"The folder will be removed. Filed entries move back to Unfiled.",
|
|
54
60
|
},
|
|
55
61
|
};
|
package/src/folders/web/index.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// @runtime client
|
|
2
2
|
export { FOLDER_SECTION_EXTENSION_NAME, FoldersHandlers, FoldersQueries } from "../constants";
|
|
3
3
|
export { foldersClient } from "./client-plugin";
|
|
4
|
-
export { FolderManager } from "./folder-manager";
|
|
4
|
+
export { type FolderFiling, type FolderLeaf, FolderManager } from "./folder-manager";
|
|
5
5
|
export { FolderSection } from "./folder-section";
|
|
6
6
|
export { buildFolderTree, type FolderNode, type FolderRow, folderPath } from "./tree";
|