@anchrd/intel-ui 0.3.0 → 0.5.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/components.json +7 -1
- package/package.json +3 -1
- package/src/app/action-slot/action-slot.tsx +23 -0
- package/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/src/app/app-tree/app-tree.tsx +798 -0
- package/src/app/app.tsx +99 -54
- package/src/app/header-search/header-search.tsx +291 -0
- package/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/src/app/tree-move/tree-move.tsx +197 -0
- package/src/app/user-footer/user-footer.tsx +103 -0
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/breadcrumb.tsx +102 -0
- package/src/components/ui/button.tsx +64 -0
- package/src/components/ui/collapsible.tsx +20 -0
- package/src/components/ui/command.tsx +160 -0
- package/src/components/ui/dialog.tsx +143 -0
- package/src/components/ui/dropdown-menu.tsx +162 -0
- package/src/components/ui/input.tsx +21 -0
- package/src/components/ui/separator.tsx +26 -0
- package/src/components/ui/sheet.tsx +136 -0
- package/src/components/ui/sidebar.tsx +693 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/tooltip.tsx +51 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +170 -85
- package/src/data/intel-data-provider/intel-data-provider.types.ts +64 -20
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +684 -368
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +174 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/hooks/use-mobile.ts +19 -0
- package/src/i18n/en.json +188 -52
- package/src/knowledge/knowledge.tsx +133 -709
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +129 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +580 -0
- package/src/router/router.tsx +4 -0
- package/src/router/selection-search.ts +43 -0
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +91 -51
- package/src/theme/theme.ts +24 -0
- package/src/tools/tools.tsx +175 -158
|
@@ -0,0 +1,798 @@
|
|
|
1
|
+
import { useMutation, useQueries, useQueryClient } from "@tanstack/react-query";
|
|
2
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
|
+
import {
|
|
4
|
+
ChevronRight,
|
|
5
|
+
CornerLeftUp,
|
|
6
|
+
FileText,
|
|
7
|
+
Folder,
|
|
8
|
+
FolderOpen,
|
|
9
|
+
Paperclip,
|
|
10
|
+
Plus,
|
|
11
|
+
Table,
|
|
12
|
+
Upload,
|
|
13
|
+
Workflow,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import { useRef, useState } from "react";
|
|
16
|
+
import {
|
|
17
|
+
type MoveDestination,
|
|
18
|
+
MoveDialog,
|
|
19
|
+
moveErrorKey,
|
|
20
|
+
moveVerdict,
|
|
21
|
+
parentOf,
|
|
22
|
+
} from "@/app/tree-move/tree-move.tsx";
|
|
23
|
+
import {
|
|
24
|
+
DropdownMenu,
|
|
25
|
+
DropdownMenuContent,
|
|
26
|
+
DropdownMenuItem,
|
|
27
|
+
DropdownMenuTrigger,
|
|
28
|
+
} from "@/components/ui/dropdown-menu";
|
|
29
|
+
import {
|
|
30
|
+
SidebarGroup,
|
|
31
|
+
SidebarMenu,
|
|
32
|
+
SidebarMenuButton,
|
|
33
|
+
SidebarMenuItem,
|
|
34
|
+
SidebarMenuSkeleton,
|
|
35
|
+
} from "@/components/ui/sidebar";
|
|
36
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
37
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
38
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
39
|
+
import { ResourceMenu } from "@/resource-menu/resource-menu.tsx";
|
|
40
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
41
|
+
import { selectedFrom } from "@/router/selection-search.ts";
|
|
42
|
+
|
|
43
|
+
type NewKind = "folder" | "document" | "table" | "flow";
|
|
44
|
+
type Creating = { parentId: string | null; kind: NewKind };
|
|
45
|
+
|
|
46
|
+
const icons = {
|
|
47
|
+
folder: Folder,
|
|
48
|
+
document: FileText,
|
|
49
|
+
attachment: Paperclip,
|
|
50
|
+
table: Table,
|
|
51
|
+
flow: Workflow,
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
// Attachments are inlined as base64, so the browser holds the file twice while it uploads.
|
|
55
|
+
const MaxUploadBytes = 15_000_000;
|
|
56
|
+
|
|
57
|
+
async function fileBase64(file: File): Promise<string> {
|
|
58
|
+
if (file.size > MaxUploadBytes) throw new Error("Attachment exceeds the 15 MB upload limit");
|
|
59
|
+
return await new Promise((resolve, reject) => {
|
|
60
|
+
const reader = new FileReader();
|
|
61
|
+
reader.onerror = () => reject(reader.error ?? new Error("Attachment could not be read"));
|
|
62
|
+
reader.onload = () => {
|
|
63
|
+
const result = reader.result;
|
|
64
|
+
if (typeof result !== "string" || !result.includes(",")) {
|
|
65
|
+
reject(new Error("Attachment could not be encoded"));
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
resolve(result.slice(result.indexOf(",") + 1));
|
|
69
|
+
};
|
|
70
|
+
reader.readAsDataURL(file);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// A level is either a folder of the shared tree or the flows one flow calls. The second kind is
|
|
75
|
+
// derived from that flow's graph, not from `parent_id` (ADR-0004 §3), which is why it is its own
|
|
76
|
+
// level rather than another folder.
|
|
77
|
+
type Level = { id: string | null; type: "folder" | "flow" };
|
|
78
|
+
|
|
79
|
+
// One key per level. `null` is the root; every expanded row adds one of its own, and nothing else is
|
|
80
|
+
// ever asked for.
|
|
81
|
+
function levelKey(level: Level): readonly unknown[] {
|
|
82
|
+
return level.type === "flow" ? ["flow-calls", level.id] : ["tree", level.id];
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
86
|
+
// plus on the moved row would still file into the folder it just left.
|
|
87
|
+
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
88
|
+
return entry.type === "knowledge"
|
|
89
|
+
? { ...entry, node: { ...entry.node, parentId } }
|
|
90
|
+
: { ...entry, flow: { ...entry.flow, parentId } };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function AppTree() {
|
|
94
|
+
const { data, i18n } = useIntelRouterContext();
|
|
95
|
+
const queryClient = useQueryClient();
|
|
96
|
+
const navigate = useNavigate();
|
|
97
|
+
const [expanded, setExpanded] = useState<readonly Level[]>([]);
|
|
98
|
+
const [creating, setCreating] = useState<Creating | null>(null);
|
|
99
|
+
const [uploadTo, setUploadTo] = useState<string | null>(null);
|
|
100
|
+
const uploadInput = useRef<HTMLInputElement>(null);
|
|
101
|
+
const [dragged, setDragged] = useState<TreeEntry | null>(null);
|
|
102
|
+
const draggedRef = useRef<TreeEntry | null>(null);
|
|
103
|
+
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
104
|
+
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
105
|
+
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
106
|
+
const [moving, setMoving] = useState<{
|
|
107
|
+
entry: TreeEntry;
|
|
108
|
+
initial: MoveDestination | null;
|
|
109
|
+
} | null>(null);
|
|
110
|
+
|
|
111
|
+
const location = useRouterState({
|
|
112
|
+
select: (state) => ({
|
|
113
|
+
pathname: state.location.pathname,
|
|
114
|
+
select: selectedFrom(state.location.search),
|
|
115
|
+
}),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// ⚠️ The whole point of #11: one query per level, and a level only exists while its folder is
|
|
119
|
+
// open. A recursive load would put an N+1 on every page of the app, because the tree is now on
|
|
120
|
+
// every page.
|
|
121
|
+
const parents: Level[] = [{ id: null, type: "folder" }, ...expanded];
|
|
122
|
+
const levels = useQueries({
|
|
123
|
+
queries: parents.map((parent) => ({
|
|
124
|
+
queryKey: levelKey(parent),
|
|
125
|
+
queryFn: async () =>
|
|
126
|
+
parent.type === "flow" && parent.id !== null
|
|
127
|
+
? // The same row a folder's level builds for a flow, from the same function: what hangs
|
|
128
|
+
// under an expanded flow differs in where the flows come from, never in what a flow row
|
|
129
|
+
// is (#30).
|
|
130
|
+
(await data.listFlowCalls(parent.id)).items.map(flowEntry)
|
|
131
|
+
: await data.listTreeChildren(parent.id),
|
|
132
|
+
})),
|
|
133
|
+
});
|
|
134
|
+
const levelFor = (level: Level) =>
|
|
135
|
+
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
136
|
+
const root = levels[0];
|
|
137
|
+
|
|
138
|
+
function toggle(level: Level & { id: string }, open: boolean) {
|
|
139
|
+
setExpanded((current) => {
|
|
140
|
+
const rest = current.filter((entry) => entry.id !== level.id || entry.type !== level.type);
|
|
141
|
+
return open ? [...rest, level] : rest;
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
|
|
146
|
+
// the whole flow list, and the graph is what the link picker and the flow editor's Knowledge step
|
|
147
|
+
// offer — a row created here has to reach those too, or another screen keeps showing yesterday.
|
|
148
|
+
async function reveal(
|
|
149
|
+
parentId: string | null,
|
|
150
|
+
collection: "flows" | "knowledge-graph",
|
|
151
|
+
): Promise<void> {
|
|
152
|
+
if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
|
|
153
|
+
await Promise.all([
|
|
154
|
+
queryClient.invalidateQueries({ queryKey: levelKey({ id: parentId, type: "folder" }) }),
|
|
155
|
+
queryClient.invalidateQueries({ queryKey: [collection] }),
|
|
156
|
+
// The graph view of that same level draws exactly this list, so it goes stale for the same
|
|
157
|
+
// reason the level does (#19).
|
|
158
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
159
|
+
]);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const create = useMutation({
|
|
163
|
+
mutationFn: async ({
|
|
164
|
+
parentId,
|
|
165
|
+
kind,
|
|
166
|
+
title,
|
|
167
|
+
columns,
|
|
168
|
+
}: Creating & { title: string; columns: string[] }) => {
|
|
169
|
+
if (kind === "flow") {
|
|
170
|
+
const flow = await data.createFlow({
|
|
171
|
+
parentId,
|
|
172
|
+
title,
|
|
173
|
+
description: null,
|
|
174
|
+
idempotencyKey: crypto.randomUUID(),
|
|
175
|
+
});
|
|
176
|
+
return { area: "/flows" as const, id: flow.id, parentId, collection: "flows" as const };
|
|
177
|
+
}
|
|
178
|
+
const node = await data.createKnowledge({
|
|
179
|
+
parentId,
|
|
180
|
+
kind,
|
|
181
|
+
title,
|
|
182
|
+
description: null,
|
|
183
|
+
contextPolicy: "relevant",
|
|
184
|
+
idempotencyKey: crypto.randomUUID(),
|
|
185
|
+
});
|
|
186
|
+
// ⚠️ Two calls, because they are two things: the node is a row in the tree, the header is the
|
|
187
|
+
// contract every later append is measured against (#40). A table without a header would sit
|
|
188
|
+
// there refusing every append, so the second call happens here rather than being left to the
|
|
189
|
+
// person who created it.
|
|
190
|
+
if (kind === "table") {
|
|
191
|
+
await data.defineKnowledgeTable({
|
|
192
|
+
nodeId: node.id,
|
|
193
|
+
columns,
|
|
194
|
+
idempotencyKey: crypto.randomUUID(),
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return {
|
|
198
|
+
area: "/knowledge" as const,
|
|
199
|
+
id: node.id,
|
|
200
|
+
parentId,
|
|
201
|
+
collection: "knowledge-graph" as const,
|
|
202
|
+
};
|
|
203
|
+
},
|
|
204
|
+
onSuccess: async (created) => {
|
|
205
|
+
setCreating(null);
|
|
206
|
+
await reveal(created.parentId, created.collection);
|
|
207
|
+
await navigate({ to: created.area, search: { select: created.id } });
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
const upload = useMutation({
|
|
212
|
+
mutationFn: async ({ parentId, file }: { parentId: string | null; file: File }) => {
|
|
213
|
+
const contentBase64 = await fileBase64(file);
|
|
214
|
+
const node = await data.createKnowledge({
|
|
215
|
+
parentId,
|
|
216
|
+
kind: "attachment",
|
|
217
|
+
title: file.name,
|
|
218
|
+
description: null,
|
|
219
|
+
contextPolicy: "relevant",
|
|
220
|
+
idempotencyKey: crypto.randomUUID(),
|
|
221
|
+
});
|
|
222
|
+
try {
|
|
223
|
+
await data.saveKnowledgeAttachment({
|
|
224
|
+
nodeId: node.id,
|
|
225
|
+
baseVersionId: null,
|
|
226
|
+
contentBase64,
|
|
227
|
+
mediaType: file.type || "application/octet-stream",
|
|
228
|
+
idempotencyKey: crypto.randomUUID(),
|
|
229
|
+
});
|
|
230
|
+
} catch (error) {
|
|
231
|
+
// An attachment node without bytes is a row nobody can open. Take it back rather than
|
|
232
|
+
// leaving it in the tree, but never let the cleanup hide the real failure.
|
|
233
|
+
const current = await data.getKnowledge(node.id).catch(() => null);
|
|
234
|
+
if (current?.version === null && current.node.updatedAt === node.updatedAt) {
|
|
235
|
+
await data
|
|
236
|
+
.archiveKnowledge({
|
|
237
|
+
nodeId: node.id,
|
|
238
|
+
baseUpdatedAt: current.node.updatedAt,
|
|
239
|
+
archived: true,
|
|
240
|
+
idempotencyKey: crypto.randomUUID(),
|
|
241
|
+
})
|
|
242
|
+
.catch(() => undefined);
|
|
243
|
+
}
|
|
244
|
+
throw error;
|
|
245
|
+
}
|
|
246
|
+
return { id: node.id, parentId };
|
|
247
|
+
},
|
|
248
|
+
onSuccess: async (created) => {
|
|
249
|
+
await reveal(created.parentId, "knowledge-graph");
|
|
250
|
+
await navigate({ to: "/knowledge", search: { select: created.id } });
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
// ⚠️ Optimistic, never authoritative. The row is lifted out of one level and dropped into the
|
|
255
|
+
// other before the server answers, and every refusal — 403, `parent_not_folder`, `move_cycle`,
|
|
256
|
+
// `update_conflict` — puts both levels back exactly as they were and then re-reads them, so a
|
|
257
|
+
// rejected move cannot leave a row standing twice or nowhere at all.
|
|
258
|
+
const move = useMutation({
|
|
259
|
+
mutationFn: async ({
|
|
260
|
+
entry,
|
|
261
|
+
destination,
|
|
262
|
+
}: {
|
|
263
|
+
entry: TreeEntry;
|
|
264
|
+
destination: MoveDestination;
|
|
265
|
+
}) => {
|
|
266
|
+
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
267
|
+
// view can act on instead of an overwrite nobody notices.
|
|
268
|
+
if (entry.type === "flow") {
|
|
269
|
+
await data.updateFlow({
|
|
270
|
+
flowId: entry.id,
|
|
271
|
+
baseUpdatedAt: entry.flow.updatedAt,
|
|
272
|
+
parentId: destination.id,
|
|
273
|
+
idempotencyKey: crypto.randomUUID(),
|
|
274
|
+
});
|
|
275
|
+
} else {
|
|
276
|
+
await data.updateKnowledge({
|
|
277
|
+
nodeId: entry.id,
|
|
278
|
+
baseUpdatedAt: entry.node.updatedAt,
|
|
279
|
+
parentId: destination.id,
|
|
280
|
+
idempotencyKey: crypto.randomUUID(),
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
},
|
|
284
|
+
onMutate: async ({ entry, destination }) => {
|
|
285
|
+
const fromKey = levelKey({ id: parentOf(entry), type: "folder" });
|
|
286
|
+
const toKey = levelKey({ id: destination.id, type: "folder" });
|
|
287
|
+
await Promise.all([
|
|
288
|
+
queryClient.cancelQueries({ queryKey: fromKey }),
|
|
289
|
+
queryClient.cancelQueries({ queryKey: toKey }),
|
|
290
|
+
]);
|
|
291
|
+
const snapshot = [
|
|
292
|
+
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
293
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
294
|
+
] as const;
|
|
295
|
+
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
296
|
+
current?.filter((row) => row.id !== entry.id),
|
|
297
|
+
);
|
|
298
|
+
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
299
|
+
// that were never read.
|
|
300
|
+
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
301
|
+
current === undefined
|
|
302
|
+
? current
|
|
303
|
+
: [...current.filter((row) => row.id !== entry.id), withParent(entry, destination.id)],
|
|
304
|
+
);
|
|
305
|
+
return { snapshot };
|
|
306
|
+
},
|
|
307
|
+
onError: (_error, _variables, context) => {
|
|
308
|
+
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
309
|
+
},
|
|
310
|
+
onSuccess: (_result, { destination }) => {
|
|
311
|
+
if (destination.id !== null) toggle({ id: destination.id, type: "folder" }, true);
|
|
312
|
+
},
|
|
313
|
+
onSettled: async (_result, _error, { entry, destination }) => {
|
|
314
|
+
await Promise.all([
|
|
315
|
+
queryClient.invalidateQueries({
|
|
316
|
+
queryKey: levelKey({ id: parentOf(entry), type: "folder" }),
|
|
317
|
+
}),
|
|
318
|
+
queryClient.invalidateQueries({
|
|
319
|
+
queryKey: levelKey({ id: destination.id, type: "folder" }),
|
|
320
|
+
}),
|
|
321
|
+
queryClient.invalidateQueries({
|
|
322
|
+
queryKey: [entry.type === "flow" ? "flows" : "knowledge-graph"],
|
|
323
|
+
}),
|
|
324
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
325
|
+
]);
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
function startMove(entry: TreeEntry, destination: MoveDestination | null) {
|
|
330
|
+
move.reset();
|
|
331
|
+
setMoving({ entry, initial: destination });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
335
|
+
// is not a target at all.
|
|
336
|
+
function verdictFor(
|
|
337
|
+
carried: TreeEntry | null,
|
|
338
|
+
targetId: string | null,
|
|
339
|
+
targetAncestors: ReadonlySet<string>,
|
|
340
|
+
) {
|
|
341
|
+
if (!carried) return undefined;
|
|
342
|
+
return moveVerdict({
|
|
343
|
+
draggedId: carried.id,
|
|
344
|
+
draggedParentId: parentOf(carried),
|
|
345
|
+
targetId,
|
|
346
|
+
targetAncestors,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function dropHandlers(target: MoveDestination, ancestors: ReadonlySet<string>) {
|
|
351
|
+
// Two readings of the same question. The render-time one drives what is on screen; the handlers
|
|
352
|
+
// ask `draggedRef` instead, because a drag's events can all arrive before React has re-rendered
|
|
353
|
+
// and a closure over the previous render would then move the previous row.
|
|
354
|
+
const verdict = verdictFor(dragged, target.id, ancestors);
|
|
355
|
+
return {
|
|
356
|
+
verdict,
|
|
357
|
+
props: {
|
|
358
|
+
onDragOver: (event: React.DragEvent) => {
|
|
359
|
+
if (verdictFor(draggedRef.current, target.id, ancestors) !== "ok") {
|
|
360
|
+
event.dataTransfer.dropEffect = "none";
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
// Without `preventDefault` the browser refuses the drop, so this is also what makes the
|
|
364
|
+
// "not allowed" cursor appear over every target the tree has ruled out.
|
|
365
|
+
event.preventDefault();
|
|
366
|
+
event.dataTransfer.dropEffect = "move";
|
|
367
|
+
setOver(target.id);
|
|
368
|
+
},
|
|
369
|
+
onDragLeave: () => setOver((current) => (current === target.id ? undefined : current)),
|
|
370
|
+
onDrop: (event: React.DragEvent) => {
|
|
371
|
+
event.preventDefault();
|
|
372
|
+
const carried = draggedRef.current;
|
|
373
|
+
setOver(undefined);
|
|
374
|
+
setDragged(null);
|
|
375
|
+
draggedRef.current = null;
|
|
376
|
+
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
377
|
+
startMove(carried, target);
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function startUpload(parentId: string | null) {
|
|
385
|
+
setUploadTo(parentId);
|
|
386
|
+
uploadInput.current?.click();
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderLevel(parent: Level, ancestors: ReadonlySet<string>, label?: string) {
|
|
390
|
+
const level = levelFor(parent);
|
|
391
|
+
if (!level || level.isPending) {
|
|
392
|
+
return (
|
|
393
|
+
<SidebarMenu aria-label={label}>
|
|
394
|
+
{[0, 1, 2].map((row) => (
|
|
395
|
+
<SidebarMenuItem key={row}>
|
|
396
|
+
<SidebarMenuSkeleton showIcon />
|
|
397
|
+
</SidebarMenuItem>
|
|
398
|
+
))}
|
|
399
|
+
</SidebarMenu>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
if (level.isError) {
|
|
403
|
+
return (
|
|
404
|
+
<div role="alert" className="space-y-2 px-2 py-1.5 text-sm">
|
|
405
|
+
<p className="text-destructive">{i18n.t("tree.failed")}</p>
|
|
406
|
+
<button
|
|
407
|
+
type="button"
|
|
408
|
+
onClick={() => void level.refetch()}
|
|
409
|
+
className="rounded-md border px-2 py-1 text-xs outline-none hover:bg-sidebar-accent focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
410
|
+
>
|
|
411
|
+
{i18n.t("common.retry")}
|
|
412
|
+
</button>
|
|
413
|
+
</div>
|
|
414
|
+
);
|
|
415
|
+
}
|
|
416
|
+
// ⚠️ The cycle guard, in the form a per-level load needs: a broken `parentId` that points back
|
|
417
|
+
// up would otherwise open the same folder inside itself for as long as anyone keeps clicking.
|
|
418
|
+
// The recursion is gone, so the depth limit that used to save us is gone with it; what stops it
|
|
419
|
+
// now is that a row already on the path from the root is not rendered again.
|
|
420
|
+
const entries = level.data?.filter((entry) => !ancestors.has(entry.id)) ?? [];
|
|
421
|
+
if (entries.length === 0) {
|
|
422
|
+
// ⚠️ An open folder with nothing in it says nothing (#24): the missing row already is the
|
|
423
|
+
// answer, and the sentence used to repeat itself once per opened folder. The other two stay,
|
|
424
|
+
// and deliberately: the empty root is the whole of the navigation, so a blank sidebar on a
|
|
425
|
+
// fresh installation would leave a new user without any way in; and "this flow calls no other
|
|
426
|
+
// flow" is a different statement from "empty" — it is an answer, not filler.
|
|
427
|
+
const message =
|
|
428
|
+
parent.type === "flow"
|
|
429
|
+
? i18n.t("tree.noCalls")
|
|
430
|
+
: parent.id === null
|
|
431
|
+
? i18n.t("tree.empty")
|
|
432
|
+
: null;
|
|
433
|
+
// The folder keeps its list, empty. The structure is what carries "there is nothing here" to
|
|
434
|
+
// a screen reader — "list, 0 items" — which is the same answer the missing rows give a reader
|
|
435
|
+
// who can see them, and neither of them is a sentence.
|
|
436
|
+
return message === null ? (
|
|
437
|
+
<SidebarMenu aria-label={label} />
|
|
438
|
+
) : (
|
|
439
|
+
<p className="px-2 py-1.5 text-sm text-muted-foreground">{message}</p>
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
return (
|
|
443
|
+
<SidebarMenu aria-label={label}>
|
|
444
|
+
{entries.map((entry) => renderRow(entry, ancestors, parent.type === "flow"))}
|
|
445
|
+
</SidebarMenu>
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// ⚠️ `derived` is the level under a flow: its rows are that flow's calls, read out of its graph
|
|
450
|
+
// (ADR-0004 §3, `levelKey`). Nothing there has a `parent_id` to rewrite, so those rows are neither
|
|
451
|
+
// dragged nor dropped on — a move made there would silently be a move of the shared flow itself.
|
|
452
|
+
function renderRow(entry: TreeEntry, ancestors: ReadonlySet<string>, derived: boolean) {
|
|
453
|
+
const isFolder = entry.kind === "folder";
|
|
454
|
+
// ⚠️ A flow expands too, and what appears under it is what it calls — read from its graph, not
|
|
455
|
+
// from `parent_id`. A flow reused by three callers therefore shows up under all three, which is
|
|
456
|
+
// the answer to "what do I run, and how" (ADR-0004 §3).
|
|
457
|
+
const level: Level & { id: string } = { id: entry.id, type: isFolder ? "folder" : "flow" };
|
|
458
|
+
const expandable = isFolder || entry.type === "flow";
|
|
459
|
+
const isOpen =
|
|
460
|
+
expandable && expanded.some((open) => open.id === entry.id && open.type === level.type);
|
|
461
|
+
const Icon = isOpen && isFolder ? FolderOpen : icons[entry.kind];
|
|
462
|
+
const area = entry.type === "flow" ? "/flows" : "/knowledge";
|
|
463
|
+
const isActive = location.select === entry.id && location.pathname === area;
|
|
464
|
+
// A row's plus files into that row's place: inside a folder, beside anything else.
|
|
465
|
+
const target = isFolder ? entry.id : parentOf(entry);
|
|
466
|
+
// Only a folder takes a drop, and only on a level that owns its rows. Everything else keeps the
|
|
467
|
+
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
468
|
+
const drop =
|
|
469
|
+
isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
|
|
470
|
+
const isDragged = dragged?.id === entry.id;
|
|
471
|
+
// While a drag is in progress every row says which of the three it is: the row being carried,
|
|
472
|
+
// a folder that would take it, or something that would not. Silence on the last two is what
|
|
473
|
+
// turns a drag into a guess that only the drop answers.
|
|
474
|
+
const highlight =
|
|
475
|
+
dragged === null || isDragged
|
|
476
|
+
? ""
|
|
477
|
+
: drop?.verdict === "ok"
|
|
478
|
+
? over === entry.id
|
|
479
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
480
|
+
: "ring-1 ring-sidebar-border"
|
|
481
|
+
: "opacity-40";
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<SidebarMenuItem key={entry.id} className="group/row">
|
|
485
|
+
{/* ⚠️ The colouring sits on the ROW, not on the button inside it — the difference the
|
|
486
|
+
ticket is about. It used to hang on `SidebarMenuButton`, whose siblings the plus and this
|
|
487
|
+
menu are, so the grey stopped short of them: two icons standing outside the very row they
|
|
488
|
+
operate, with a pale strip left over on the right. Taken from gate, where the same
|
|
489
|
+
mistake was made and unmade three times (`service-tree.tsx`, GATE-76/-78/-81) — there the
|
|
490
|
+
row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
|
|
491
|
+
The button keeps its own hover and active fill switched off rather than doubled, so the
|
|
492
|
+
row shows one surface instead of two overlapping ones. */}
|
|
493
|
+
<div
|
|
494
|
+
className={`flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
|
|
495
|
+
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
|
|
496
|
+
}`}
|
|
497
|
+
>
|
|
498
|
+
{expandable ? (
|
|
499
|
+
<button
|
|
500
|
+
type="button"
|
|
501
|
+
aria-expanded={isOpen}
|
|
502
|
+
aria-label={i18n.t(
|
|
503
|
+
isFolder
|
|
504
|
+
? isOpen
|
|
505
|
+
? "tree.collapse"
|
|
506
|
+
: "tree.expand"
|
|
507
|
+
: isOpen
|
|
508
|
+
? "tree.collapseCalls"
|
|
509
|
+
: "tree.expandCalls",
|
|
510
|
+
{ title: entry.title },
|
|
511
|
+
)}
|
|
512
|
+
onClick={() => toggle(level, !isOpen)}
|
|
513
|
+
// The icons inside a coloured row need a step of their own, or their hover would be
|
|
514
|
+
// the row's colour on the row's colour and read as nothing at all.
|
|
515
|
+
className="shrink-0 rounded-md p-1 text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring"
|
|
516
|
+
>
|
|
517
|
+
<ChevronRight
|
|
518
|
+
aria-hidden="true"
|
|
519
|
+
className={`size-3.5 transition-transform ${isOpen ? "rotate-90" : ""}`}
|
|
520
|
+
/>
|
|
521
|
+
</button>
|
|
522
|
+
) : (
|
|
523
|
+
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
524
|
+
)}
|
|
525
|
+
{/* The row's own control is what is grabbed and what is dropped on: a real button, so the
|
|
526
|
+
gesture rides on something that is already focusable and named rather than on a bare
|
|
527
|
+
div that assistive technology would have to be told about twice. */}
|
|
528
|
+
<SidebarMenuButton
|
|
529
|
+
isActive={isActive}
|
|
530
|
+
draggable={!derived}
|
|
531
|
+
onDragStart={(event) => {
|
|
532
|
+
event.dataTransfer.effectAllowed = "move";
|
|
533
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
534
|
+
draggedRef.current = entry;
|
|
535
|
+
setDragged(entry);
|
|
536
|
+
}}
|
|
537
|
+
onDragEnd={() => {
|
|
538
|
+
draggedRef.current = null;
|
|
539
|
+
setDragged(null);
|
|
540
|
+
setOver(undefined);
|
|
541
|
+
}}
|
|
542
|
+
{...(drop?.props ?? {})}
|
|
543
|
+
data-drop={
|
|
544
|
+
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
545
|
+
}
|
|
546
|
+
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${derived ? "" : "cursor-grab active:cursor-grabbing"} ${isDragged ? "opacity-50" : ""} ${highlight}`}
|
|
547
|
+
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
548
|
+
>
|
|
549
|
+
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
550
|
+
<span className="truncate">{entry.title}</span>
|
|
551
|
+
{/* The icon carries the type on screen; this carries it everywhere else, and it is why
|
|
552
|
+
a flow needs no suffix in its name to be told from a document. */}
|
|
553
|
+
<span className="sr-only">{i18n.t(`tree.kind.${entry.kind}`)}</span>
|
|
554
|
+
</SidebarMenuButton>
|
|
555
|
+
<AddMenu
|
|
556
|
+
label={i18n.t("tree.add", { title: entry.title })}
|
|
557
|
+
onSelect={(kind) => {
|
|
558
|
+
if (kind === "upload") startUpload(target);
|
|
559
|
+
else setCreating({ parentId: target, kind });
|
|
560
|
+
}}
|
|
561
|
+
/>
|
|
562
|
+
{/* ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no
|
|
563
|
+
touch worth the name. A capability that exists only for a mouse is missing for
|
|
564
|
+
everyone else, so moving has a second, equal route — this menu's folder picker. #27
|
|
565
|
+
opened the menu with that one item; the rest of the row's actions moved in here from
|
|
566
|
+
the header, which is what #24 is. A row under a flow is what that flow calls, read out
|
|
567
|
+
of its graph rather than from `parent_id` (ADR-0004 §3): it has no place of its own to
|
|
568
|
+
be moved from, so it gets no move — and no menu, since renaming the shared flow from
|
|
569
|
+
under one of its callers would rename it for all of them without saying so. */}
|
|
570
|
+
{derived ? null : (
|
|
571
|
+
<ResourceMenu target={entry} variant="row" onMove={() => startMove(entry, null)} />
|
|
572
|
+
)}
|
|
573
|
+
</div>
|
|
574
|
+
{/* The same rows one indent deeper: one row component for every depth, so the plus on the
|
|
575
|
+
fourth level is the same plus as on the first. The list is named after the row it hangs
|
|
576
|
+
under — indentation says whose contents these are to anyone who can see it, and this is
|
|
577
|
+
the same sentence for anyone who cannot. */}
|
|
578
|
+
{isOpen ? (
|
|
579
|
+
<div className="ml-3.5 border-l border-sidebar-border pl-1.5">
|
|
580
|
+
{renderLevel(
|
|
581
|
+
level,
|
|
582
|
+
new Set([...ancestors, entry.id]),
|
|
583
|
+
i18n.t(isFolder ? "tree.contents" : "tree.calls", { title: entry.title }),
|
|
584
|
+
)}
|
|
585
|
+
</div>
|
|
586
|
+
) : null}
|
|
587
|
+
</SidebarMenuItem>
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const rootDrop = dropHandlers({ id: null, title: i18n.t("tree.move.root") }, new Set());
|
|
592
|
+
|
|
593
|
+
return (
|
|
594
|
+
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
|
|
595
|
+
{/* The root has no row to drop on, so while something is being carried it gets one. It is the
|
|
596
|
+
only way back out of a folder, and it appears exactly when it can be used. */}
|
|
597
|
+
{rootDrop.verdict === undefined || dragged === null ? null : (
|
|
598
|
+
<button
|
|
599
|
+
type="button"
|
|
600
|
+
{...rootDrop.props}
|
|
601
|
+
onClick={() => startMove(dragged, { id: null, title: i18n.t("tree.move.root") })}
|
|
602
|
+
data-drop={rootDrop.verdict}
|
|
603
|
+
className={`mb-1 flex w-full items-center gap-1 rounded-md border border-dashed px-2 py-1.5 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${
|
|
604
|
+
rootDrop.verdict === "ok"
|
|
605
|
+
? over === null
|
|
606
|
+
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
607
|
+
: "text-sidebar-foreground"
|
|
608
|
+
: "text-muted-foreground opacity-40"
|
|
609
|
+
}`}
|
|
610
|
+
>
|
|
611
|
+
<CornerLeftUp aria-hidden="true" className="size-4 shrink-0" />
|
|
612
|
+
{i18n.t("tree.move.dropRoot")}
|
|
613
|
+
</button>
|
|
614
|
+
)}
|
|
615
|
+
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
616
|
+
named for assistive technology instead. */}
|
|
617
|
+
{renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
|
|
618
|
+
{create.isError || upload.isError ? (
|
|
619
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
620
|
+
{upload.isError ? i18n.t("tree.uploadFailed") : i18n.t("tree.createFailed")}
|
|
621
|
+
</p>
|
|
622
|
+
) : null}
|
|
623
|
+
{/* Four refusals, four sentences — and by the time one is read the row is already back where
|
|
624
|
+
it started, because the rollback happens in `onError` rather than here. */}
|
|
625
|
+
{move.isError ? (
|
|
626
|
+
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
627
|
+
{i18n.t(moveErrorKey(move.error))}
|
|
628
|
+
</p>
|
|
629
|
+
) : null}
|
|
630
|
+
{/* The root has no row of its own, so it gets one — otherwise a tree could only ever grow
|
|
631
|
+
inside the folders it already has, and the plus would stand loose under the list, attached
|
|
632
|
+
to nothing (#24). As a whole row it is also the one thing on screen when the tree is empty,
|
|
633
|
+
which is exactly when somebody needs to be told where to start. */}
|
|
634
|
+
{root?.isPending ? null : (
|
|
635
|
+
<div className="mt-1 flex items-center gap-0.5">
|
|
636
|
+
{/* The gutter the disclosure arrows occupy, so the plus lines up with the row icons
|
|
637
|
+
above it rather than half a step to their left. */}
|
|
638
|
+
<span aria-hidden="true" className="size-5 shrink-0" />
|
|
639
|
+
<AddMenu
|
|
640
|
+
label={i18n.t("tree.addRoot")}
|
|
641
|
+
variant="row"
|
|
642
|
+
onSelect={(kind) => {
|
|
643
|
+
if (kind === "upload") startUpload(null);
|
|
644
|
+
else setCreating({ parentId: null, kind });
|
|
645
|
+
}}
|
|
646
|
+
/>
|
|
647
|
+
</div>
|
|
648
|
+
)}
|
|
649
|
+
<input
|
|
650
|
+
ref={uploadInput}
|
|
651
|
+
type="file"
|
|
652
|
+
className="sr-only"
|
|
653
|
+
aria-label={i18n.t("tree.upload")}
|
|
654
|
+
onChange={(event) => {
|
|
655
|
+
const file = event.target.files?.[0];
|
|
656
|
+
if (file) upload.mutate({ parentId: uploadTo, file });
|
|
657
|
+
event.currentTarget.value = "";
|
|
658
|
+
}}
|
|
659
|
+
/>
|
|
660
|
+
{creating ? (
|
|
661
|
+
<Modal title={i18n.t(`tree.new.${creating.kind}`)} close={() => setCreating(null)}>
|
|
662
|
+
<CreateForm
|
|
663
|
+
kind={creating.kind}
|
|
664
|
+
pending={create.isPending}
|
|
665
|
+
submit={(title, columns) => create.mutate({ ...creating, title, columns })}
|
|
666
|
+
/>
|
|
667
|
+
</Modal>
|
|
668
|
+
) : null}
|
|
669
|
+
{moving ? (
|
|
670
|
+
<MoveDialog
|
|
671
|
+
entry={moving.entry}
|
|
672
|
+
initial={moving.initial}
|
|
673
|
+
close={() => setMoving(null)}
|
|
674
|
+
submit={(destination) => {
|
|
675
|
+
setMoving(null);
|
|
676
|
+
move.mutate({ entry: moving.entry, destination });
|
|
677
|
+
}}
|
|
678
|
+
/>
|
|
679
|
+
) : null}
|
|
680
|
+
</SidebarGroup>
|
|
681
|
+
);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
// ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
|
|
685
|
+
// having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
|
|
686
|
+
//
|
|
687
|
+
// `row` is the root's form: a full-width row that says what it does, because the root has no title
|
|
688
|
+
// beside which a bare icon would mean anything.
|
|
689
|
+
function AddMenu({
|
|
690
|
+
label,
|
|
691
|
+
variant = "icon",
|
|
692
|
+
onSelect,
|
|
693
|
+
}: {
|
|
694
|
+
label: string;
|
|
695
|
+
variant?: "icon" | "row";
|
|
696
|
+
onSelect(kind: NewKind | "upload"): void;
|
|
697
|
+
}) {
|
|
698
|
+
const { i18n } = useIntelRouterContext();
|
|
699
|
+
const items: Array<{ kind: NewKind | "upload"; labelKey: string; Icon: typeof Plus }> = [
|
|
700
|
+
{ kind: "folder", labelKey: "tree.new.folder", Icon: Folder },
|
|
701
|
+
{ kind: "document", labelKey: "tree.new.document", Icon: FileText },
|
|
702
|
+
{ kind: "table", labelKey: "tree.new.table", Icon: Table },
|
|
703
|
+
{ kind: "upload", labelKey: "tree.new.upload", Icon: Upload },
|
|
704
|
+
{ kind: "flow", labelKey: "tree.new.flow", Icon: Workflow },
|
|
705
|
+
];
|
|
706
|
+
return (
|
|
707
|
+
<DropdownMenu>
|
|
708
|
+
<DropdownMenuTrigger
|
|
709
|
+
{...(variant === "row" ? {} : { "aria-label": label })}
|
|
710
|
+
className={
|
|
711
|
+
variant === "row"
|
|
712
|
+
? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
|
|
713
|
+
: "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
714
|
+
}
|
|
715
|
+
>
|
|
716
|
+
<Plus aria-hidden="true" className="size-4 shrink-0" />
|
|
717
|
+
{variant === "row" ? <span className="truncate">{label}</span> : null}
|
|
718
|
+
</DropdownMenuTrigger>
|
|
719
|
+
<DropdownMenuContent align="start" className="w-44">
|
|
720
|
+
{items.map((item) => (
|
|
721
|
+
<DropdownMenuItem key={item.kind} onSelect={() => onSelect(item.kind)}>
|
|
722
|
+
<item.Icon aria-hidden="true" />
|
|
723
|
+
{i18n.t(item.labelKey)}
|
|
724
|
+
</DropdownMenuItem>
|
|
725
|
+
))}
|
|
726
|
+
</DropdownMenuContent>
|
|
727
|
+
</DropdownMenu>
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
// The column names of a new table, as one line. A comma-separated list is what a CSV header is,
|
|
732
|
+
// so the field looks like the thing it produces rather than like a form that hides it.
|
|
733
|
+
function columnsOf(value: string): string[] {
|
|
734
|
+
return value
|
|
735
|
+
.split(",")
|
|
736
|
+
.map((column) => column.trim())
|
|
737
|
+
.filter(Boolean);
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
function CreateForm({
|
|
741
|
+
kind,
|
|
742
|
+
pending,
|
|
743
|
+
submit,
|
|
744
|
+
}: {
|
|
745
|
+
kind: NewKind;
|
|
746
|
+
pending: boolean;
|
|
747
|
+
submit(title: string, columns: string[]): void;
|
|
748
|
+
}) {
|
|
749
|
+
const { i18n } = useIntelRouterContext();
|
|
750
|
+
const [title, setTitle] = useState("");
|
|
751
|
+
const [columns, setColumns] = useState("");
|
|
752
|
+
const parsedColumns = columnsOf(columns);
|
|
753
|
+
const incomplete = title.trim().length === 0 || (kind === "table" && parsedColumns.length === 0);
|
|
754
|
+
return (
|
|
755
|
+
<form
|
|
756
|
+
className="space-y-4"
|
|
757
|
+
onSubmit={(event) => {
|
|
758
|
+
event.preventDefault();
|
|
759
|
+
if (!pending && !incomplete) submit(title.trim(), parsedColumns);
|
|
760
|
+
}}
|
|
761
|
+
>
|
|
762
|
+
<label className="block text-sm font-medium">
|
|
763
|
+
{i18n.t("common.title")}
|
|
764
|
+
<input
|
|
765
|
+
required
|
|
766
|
+
value={title}
|
|
767
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
768
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
769
|
+
/>
|
|
770
|
+
</label>
|
|
771
|
+
{kind === "table" ? (
|
|
772
|
+
<label className="block text-sm font-medium">
|
|
773
|
+
{i18n.t("tree.table.columns")}
|
|
774
|
+
<input
|
|
775
|
+
required
|
|
776
|
+
value={columns}
|
|
777
|
+
onChange={(event) => setColumns(event.target.value)}
|
|
778
|
+
aria-describedby="create-table-columns-hint"
|
|
779
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
780
|
+
/>
|
|
781
|
+
<span
|
|
782
|
+
id="create-table-columns-hint"
|
|
783
|
+
className="mt-1 block text-xs font-normal text-muted-foreground"
|
|
784
|
+
>
|
|
785
|
+
{i18n.t("tree.table.columnsHint")}
|
|
786
|
+
</span>
|
|
787
|
+
</label>
|
|
788
|
+
) : null}
|
|
789
|
+
<button
|
|
790
|
+
type="submit"
|
|
791
|
+
disabled={pending || incomplete}
|
|
792
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
793
|
+
>
|
|
794
|
+
{pending ? i18n.t("common.saving") : i18n.t("common.create")}
|
|
795
|
+
</button>
|
|
796
|
+
</form>
|
|
797
|
+
);
|
|
798
|
+
}
|