@anchrd/intel-ui 0.25.0 → 0.29.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 +2 -2
- package/src/app/app-tree/app-tree.tsx +90 -40
- package/src/app/tree-move/tree-move.tsx +119 -22
- package/src/app/view-toggle/view-toggle.tsx +1 -1
- package/src/archive/archive.tsx +7 -6
- package/src/data/intel-data-provider/intel-data-provider.ts +4 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +2 -0
- package/src/flow-runs/flow-runs.tsx +5 -5
- package/src/flows/flows.tsx +50 -102
- package/src/folder-contents/folder-contents.tsx +8 -8
- package/src/i18n/de.json +9 -5
- package/src/i18n/en.json +9 -5
- package/src/i18n/es.json +10 -6
- package/src/import-bundle/import-bundle.tsx +5 -2
- package/src/main.tsx +6 -3
- package/src/node-editor/node-editor.tsx +4 -11
- package/src/node-table/node-table.tsx +3 -2
- package/src/nodes/nodes.tsx +5 -78
- package/src/resource-menu/resource-menu.tsx +92 -37
- package/src/save-button/save-button.tsx +12 -31
- package/src/time/relative-time.tsx +41 -0
- package/src/time/time-context.tsx +7 -1
- package/src/time/time.ts +25 -0
- package/src/title-row/title-row.tsx +124 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": {
|
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
"typecheck": "tsc --noEmit"
|
|
34
34
|
},
|
|
35
35
|
"dependencies": {
|
|
36
|
-
"@anchrd/intel-contract": "^0.
|
|
36
|
+
"@anchrd/intel-contract": "^0.18.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "lucide-react";
|
|
13
13
|
import { useRef, useState } from "react";
|
|
14
14
|
import {
|
|
15
|
+
allTreeLevelsKey,
|
|
15
16
|
type MoveDestination,
|
|
16
17
|
moveErrorKey,
|
|
17
18
|
moveVerdict,
|
|
@@ -85,6 +86,16 @@ function levelKey(level: Level): readonly unknown[] {
|
|
|
85
86
|
return level.type === "flow" ? ["flow-calls", level.id] : treeLevelKey(level.id);
|
|
86
87
|
}
|
|
87
88
|
|
|
89
|
+
// What a drag carries: the row, and the LEVEL it was picked up from.
|
|
90
|
+
//
|
|
91
|
+
// ⚠️ The two are not the same, and that is the whole of #446. Since #429 the root level also shows
|
|
92
|
+
// rows whose own record names a folder this reader may never see — a shared document reaches the
|
|
93
|
+
// root only through the share. Asking `parentOf` again during the move answers with that invisible
|
|
94
|
+
// folder, and then the root offers "move to the top" to a row already at the top, while the level
|
|
95
|
+
// it actually left is never cleaned up. The level is known where the row is rendered; carrying it
|
|
96
|
+
// is cheaper than deriving it wrongly.
|
|
97
|
+
type Carried = { entry: TreeEntry; level: string | null };
|
|
98
|
+
|
|
88
99
|
// What a row puts on the clipboard for anybody outside the tree. Its own media type rather than
|
|
89
100
|
// `text/plain`, so a drop target that means "move this row" and one that means "make a node for
|
|
90
101
|
// this" cannot be confused by the same payload.
|
|
@@ -99,8 +110,8 @@ export function AppTree() {
|
|
|
99
110
|
const [creating, setCreating] = useState<Creating | null>(null);
|
|
100
111
|
const [uploadTo, setUploadTo] = useState<string | null>(null);
|
|
101
112
|
const uploadInput = useRef<HTMLInputElement>(null);
|
|
102
|
-
const [dragged, setDragged] = useState<
|
|
103
|
-
const draggedRef = useRef<
|
|
113
|
+
const [dragged, setDragged] = useState<Carried | null>(null);
|
|
114
|
+
const draggedRef = useRef<Carried | null>(null);
|
|
104
115
|
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
105
116
|
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
106
117
|
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
@@ -166,13 +177,18 @@ export function AppTree() {
|
|
|
166
177
|
// ⚠️ The level a row appears in is not the only thing that goes stale. The header search reads
|
|
167
178
|
// the whole flow list, and the graph is what the link picker and the flow editor's tree links
|
|
168
179
|
// offer — a row created here has to reach those too, or another screen keeps showing yesterday.
|
|
180
|
+
//
|
|
181
|
+
// ⚠️ And the level written into is not enough either: the folder's own ROW says whether it can be
|
|
182
|
+
// opened at all, and that row stands one level higher (`allTreeLevelsKey`, #519). Without it the
|
|
183
|
+
// `toggle` above opens a folder whose arrow never appeared, so the first thing filed into an empty
|
|
184
|
+
// folder was invisible until the page was reloaded.
|
|
169
185
|
async function reveal(
|
|
170
186
|
parentId: string | null,
|
|
171
187
|
collection: "flows" | "node-graph",
|
|
172
188
|
): Promise<void> {
|
|
173
189
|
if (parentId !== null) toggle({ id: parentId, type: "folder" }, true);
|
|
174
190
|
await Promise.all([
|
|
175
|
-
queryClient.invalidateQueries({ queryKey:
|
|
191
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
176
192
|
queryClient.invalidateQueries({ queryKey: [collection] }),
|
|
177
193
|
// The graph view of that same level draws exactly this list, so it goes stale for the same
|
|
178
194
|
// reason the level does (#19).
|
|
@@ -223,8 +239,19 @@ export function AppTree() {
|
|
|
223
239
|
},
|
|
224
240
|
onSuccess: async (created) => {
|
|
225
241
|
setCreating(null);
|
|
226
|
-
|
|
227
|
-
|
|
242
|
+
// ⚠️ The screen first, the refreshes after. Since #519 `reveal` waits on EVERY mounted level,
|
|
243
|
+
// and a React Query retry is paused while the tab is not in front (#212) — awaiting that
|
|
244
|
+
// before navigating would leave somebody who just made a document sitting on the old screen
|
|
245
|
+
// for as long as one level takes to answer.
|
|
246
|
+
//
|
|
247
|
+
// ⚠️ `finally`, because both screens are lazy chunks: a failed chunk load throws out of
|
|
248
|
+
// `navigate`, and without this the tree, the collections and the graph would silently keep
|
|
249
|
+
// yesterday's rows for the rest of the session — the very state this ticket is about.
|
|
250
|
+
try {
|
|
251
|
+
await navigate({ to: created.area, search: { select: created.id } });
|
|
252
|
+
} finally {
|
|
253
|
+
await reveal(created.parentId, created.collection);
|
|
254
|
+
}
|
|
228
255
|
},
|
|
229
256
|
});
|
|
230
257
|
|
|
@@ -265,22 +292,27 @@ export function AppTree() {
|
|
|
265
292
|
return { id: node.id, parentId };
|
|
266
293
|
},
|
|
267
294
|
onSuccess: async (created) => {
|
|
268
|
-
|
|
269
|
-
|
|
295
|
+
// The order — and the `finally` — are the ones above, for the same two reasons.
|
|
296
|
+
try {
|
|
297
|
+
await navigate({ to: "/nodes", search: { select: created.id } });
|
|
298
|
+
} finally {
|
|
299
|
+
await reveal(created.parentId, "node-graph");
|
|
300
|
+
}
|
|
270
301
|
},
|
|
271
302
|
});
|
|
272
303
|
|
|
273
304
|
// What a drop on this target would do. `undefined` means nothing is being dragged, so the target
|
|
274
305
|
// is not a target at all.
|
|
275
306
|
function verdictFor(
|
|
276
|
-
carried:
|
|
307
|
+
carried: Carried | null,
|
|
277
308
|
targetId: string | null,
|
|
278
309
|
targetAncestors: ReadonlySet<string>,
|
|
279
310
|
) {
|
|
280
311
|
if (!carried) return undefined;
|
|
281
312
|
return moveVerdict({
|
|
282
|
-
draggedId: carried.id,
|
|
283
|
-
draggedParentId: parentOf(carried),
|
|
313
|
+
draggedId: carried.entry.id,
|
|
314
|
+
draggedParentId: parentOf(carried.entry),
|
|
315
|
+
draggedLevel: carried.level,
|
|
284
316
|
targetId,
|
|
285
317
|
targetAncestors,
|
|
286
318
|
});
|
|
@@ -313,7 +345,7 @@ export function AppTree() {
|
|
|
313
345
|
setDragged(null);
|
|
314
346
|
draggedRef.current = null;
|
|
315
347
|
if (carried && verdictFor(carried, target.id, ancestors) === "ok") {
|
|
316
|
-
move.start(carried, target);
|
|
348
|
+
move.start(carried.entry, carried.level, target);
|
|
317
349
|
}
|
|
318
350
|
},
|
|
319
351
|
},
|
|
@@ -436,7 +468,7 @@ export function AppTree() {
|
|
|
436
468
|
// default cursor while a drag is in progress, which is the answer "not here" without a word.
|
|
437
469
|
const drop =
|
|
438
470
|
isFolder && !derived ? dropHandlers({ id: entry.id, title: entry.title }, ancestors) : null;
|
|
439
|
-
const isDragged = dragged?.id === entry.id;
|
|
471
|
+
const isDragged = dragged?.entry.id === entry.id;
|
|
440
472
|
// While a drag is in progress every row says which of the three it is: the row being carried,
|
|
441
473
|
// a folder that would take it, or something that would not. Silence on the last two is what
|
|
442
474
|
// turns a drag into a guess that only the drop answers.
|
|
@@ -465,10 +497,41 @@ export function AppTree() {
|
|
|
465
497
|
row carries the fill and `flex-1` on the label pushes the actions to the end *inside* it.
|
|
466
498
|
The button keeps its own hover and active fill switched off rather than doubled, so the
|
|
467
499
|
row shows one surface instead of two overlapping ones. */}
|
|
500
|
+
{/* biome-ignore lint/a11y/noStaticElementInteractions: Native drag is the pointer shortcut;
|
|
501
|
+
the nested button and move dialog remain the keyboard path. */}
|
|
468
502
|
<div
|
|
503
|
+
draggable={!derived}
|
|
504
|
+
onDragStart={(event) => {
|
|
505
|
+
// ⚠️ `draggable={!derived}` is not enough on its own: `dragstart` bubbles, so a drag
|
|
506
|
+
// begun on a descendant of a derived row would still land here — and carry a level
|
|
507
|
+
// (`["tree", <flow-id>]`) that nothing ever wrote. That is the very class of key #446
|
|
508
|
+
// is about, so the handler refuses it rather than trusting the attribute.
|
|
509
|
+
if (derived) return;
|
|
510
|
+
// Native drag initiation on form controls is not interoperable: Safari can leave a
|
|
511
|
+
// draggable button looking grabbable without ever starting the drag. The row owns the
|
|
512
|
+
// gesture while its button remains the keyboard-reachable navigation control (#483).
|
|
513
|
+
event.dataTransfer.effectAllowed = "copyMove";
|
|
514
|
+
event.dataTransfer.setData("text/plain", entry.id);
|
|
515
|
+
event.dataTransfer.setData(
|
|
516
|
+
TreeEntryMediaType,
|
|
517
|
+
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
518
|
+
);
|
|
519
|
+
// The level travels with the row, read from the level this row was rendered into —
|
|
520
|
+
// never from its own record, which for a shared row names a folder the reader cannot
|
|
521
|
+
// see (#446).
|
|
522
|
+
const carried: Carried = { entry, level: parent.id };
|
|
523
|
+
draggedRef.current = carried;
|
|
524
|
+
setDragged(carried);
|
|
525
|
+
}}
|
|
526
|
+
onDragEnd={() => {
|
|
527
|
+
draggedRef.current = null;
|
|
528
|
+
setDragged(null);
|
|
529
|
+
setOver(undefined);
|
|
530
|
+
}}
|
|
531
|
+
{...(drop?.props ?? {})}
|
|
469
532
|
className={`group/row flex items-center gap-0.5 rounded-md pr-1 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground ${
|
|
470
533
|
isActive ? "bg-sidebar-accent font-medium text-sidebar-accent-foreground" : ""
|
|
471
|
-
}`}
|
|
534
|
+
} ${derived ? "" : "cursor-grab active:cursor-grabbing"}`}
|
|
472
535
|
>
|
|
473
536
|
{expandable ? (
|
|
474
537
|
<button
|
|
@@ -508,37 +571,14 @@ export function AppTree() {
|
|
|
508
571
|
// 2px left of one with an arrow, and nothing said why.
|
|
509
572
|
<span aria-hidden="true" className={TREE_GUTTER} />
|
|
510
573
|
)}
|
|
511
|
-
{/*
|
|
512
|
-
|
|
513
|
-
div that assistive technology would have to be told about twice. */}
|
|
574
|
+
{/* Navigation stays a real button. The enclosing row carries the native drag gesture;
|
|
575
|
+
putting `draggable` on this form control is precisely the Safari failure from #483. */}
|
|
514
576
|
<SidebarMenuButton
|
|
515
577
|
isActive={isActive}
|
|
516
|
-
draggable={!derived}
|
|
517
|
-
onDragStart={(event) => {
|
|
518
|
-
// ⚠️ Two formats, one gesture. `text/plain` is what a drop inside the tree reads —
|
|
519
|
-
// that is a MOVE, and it only ever needed the id. The flow canvas needs the kind as
|
|
520
|
-
// well, to know which node to make, and resolving an id there would mean a second
|
|
521
|
-
// read of something the drag already knows. `effectAllowed` says both are allowed;
|
|
522
|
-
// each drop target picks the one it means (#75).
|
|
523
|
-
event.dataTransfer.effectAllowed = "copyMove";
|
|
524
|
-
event.dataTransfer.setData("text/plain", entry.id);
|
|
525
|
-
event.dataTransfer.setData(
|
|
526
|
-
TreeEntryMediaType,
|
|
527
|
-
JSON.stringify({ id: entry.id, kind: entry.kind, title: entry.title }),
|
|
528
|
-
);
|
|
529
|
-
draggedRef.current = entry;
|
|
530
|
-
setDragged(entry);
|
|
531
|
-
}}
|
|
532
|
-
onDragEnd={() => {
|
|
533
|
-
draggedRef.current = null;
|
|
534
|
-
setDragged(null);
|
|
535
|
-
setOver(undefined);
|
|
536
|
-
}}
|
|
537
|
-
{...(drop?.props ?? {})}
|
|
538
578
|
data-drop={
|
|
539
579
|
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
540
580
|
}
|
|
541
|
-
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${
|
|
581
|
+
className={`min-w-0 flex-1 hover:bg-transparent data-[active=true]:bg-transparent ${isDragged ? "opacity-50" : ""} ${highlight}`}
|
|
542
582
|
onClick={() => void navigate({ to: area, search: { select: entry.id } })}
|
|
543
583
|
>
|
|
544
584
|
<Icon aria-hidden="true" className="size-4 shrink-0" />
|
|
@@ -588,7 +628,17 @@ export function AppTree() {
|
|
|
588
628
|
<button
|
|
589
629
|
type="button"
|
|
590
630
|
{...rootDrop.props}
|
|
591
|
-
onClick={() =>
|
|
631
|
+
onClick={() => {
|
|
632
|
+
// The strip is drawn for every verdict — greyed out when it would refuse — so the click
|
|
633
|
+
// has to read it too. Without this, a click on the grey strip performs exactly the write
|
|
634
|
+
// the drop path refuses, and `initial` being set skips the picker where the verdict is
|
|
635
|
+
// otherwise enforced.
|
|
636
|
+
if (rootDrop.verdict !== "ok") return;
|
|
637
|
+
move.start(dragged.entry, dragged.level, {
|
|
638
|
+
id: null,
|
|
639
|
+
title: i18n.t("tree.move.root"),
|
|
640
|
+
});
|
|
641
|
+
}}
|
|
592
642
|
data-drop={rootDrop.verdict}
|
|
593
643
|
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 ${
|
|
594
644
|
rootDrop.verdict === "ok"
|
|
@@ -15,6 +15,12 @@ export type MoveVerdict = "ok" | "self" | "descendant" | "same-place";
|
|
|
15
15
|
|
|
16
16
|
// The parent a row is filed under. Nodes and Flows keep their own record (ADR-0004), so the
|
|
17
17
|
// answer is read from whichever one this row is, never from a merged shape.
|
|
18
|
+
//
|
|
19
|
+
// ⚠️ This is the parent the RECORD names, which since #429 is not always the level the row is shown
|
|
20
|
+
// in: a shared document is filed inside the sharer's folder and reaches the root only through the
|
|
21
|
+
// share. Everything about a move — which level loses the row, which one keeps it, and whether the
|
|
22
|
+
// root is "same place" — is a question about the level on screen, so the level travels with the
|
|
23
|
+
// move instead of being read back out of the record here (#446).
|
|
18
24
|
export function parentOf(entry: TreeEntry): string | null {
|
|
19
25
|
return entry.type === "node" ? entry.node.parentId : entry.flow.parentId;
|
|
20
26
|
}
|
|
@@ -25,6 +31,57 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
|
25
31
|
return ["tree", parentId];
|
|
26
32
|
}
|
|
27
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Every level of the tree at once — what a write that changed what is IN a folder makes stale.
|
|
36
|
+
*
|
|
37
|
+
* ⚠️ Not the level that was written into: that is the obvious half, and it was the whole of #519.
|
|
38
|
+
* A folder ROW carries `openable` — since #59 the arrow only stands where there is something to
|
|
39
|
+
* open — and that is a statement about the folder's CONTENTS living one level ABOVE it. Refresh
|
|
40
|
+
* only the level below and the row keeps yesterday's answer: the first thing filed into an EMPTY
|
|
41
|
+
* folder has no arrow to appear under, `reveal` opens a level `renderRow` then never renders, and
|
|
42
|
+
* reloading the page was the only thing that read the row again. The other direction is the same
|
|
43
|
+
* bug from the other side — the last child moved or archived out leaves an arrow promising a level
|
|
44
|
+
* that is empty.
|
|
45
|
+
*
|
|
46
|
+
* ⚠️ A prefix rather than "the level, plus the level its folder row sits in", because that second
|
|
47
|
+
* level is not always the one the record names: since #429 a shared row also stands at the root
|
|
48
|
+
* (`levelsToClear`).
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ What it costs is one request per MOUNTED level, and that is not the same as "per level on
|
|
51
|
+
* screen": `expanded` in `app-tree.tsx` is only ever added to, so a folder that was opened and has
|
|
52
|
+
* since left the tree — archived, moved away — keeps its query mounted for the rest of the session
|
|
53
|
+
* and is re-read by every write from then on. Nothing is wrong on screen; the requests are simply
|
|
54
|
+
* paid for. #521 prunes that list, and until it does, this is the honest bound.
|
|
55
|
+
*
|
|
56
|
+
* The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
|
|
57
|
+
* anywhere, and the screen writing it does not know where.
|
|
58
|
+
*/
|
|
59
|
+
export const allTreeLevelsKey: readonly unknown[] = ["tree"];
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which cached levels a move has to take the row out of.
|
|
63
|
+
*
|
|
64
|
+
* ⚠️ Normally one, and the caller names it. When it does not — the title line has no tree level to
|
|
65
|
+
* name — the answer is not a guess but the full set of places the row can be standing in, and that
|
|
66
|
+
* set has exactly two members.
|
|
67
|
+
*
|
|
68
|
+
* It is two rather than "all of them" because of how a level is read (`db.ts`): every level except
|
|
69
|
+
* the root is strictly `parent_id IS ?`, so there the level and the record always agree. Only the
|
|
70
|
+
* root also collects the top of every readable region — a shared row whose own record names the
|
|
71
|
+
* sharer's folder (#429). So a row is either in the level its record names, or in the root.
|
|
72
|
+
*
|
|
73
|
+
* Cleaning up one level too many costs a re-read of a list the reader is looking at anyway.
|
|
74
|
+
* Cleaning up one too few is #446: the row stays where it no longer is.
|
|
75
|
+
*/
|
|
76
|
+
export function levelsToClear(
|
|
77
|
+
entry: TreeEntry,
|
|
78
|
+
level: string | null | undefined,
|
|
79
|
+
): (string | null)[] {
|
|
80
|
+
if (level !== undefined) return [level];
|
|
81
|
+
const recorded = parentOf(entry);
|
|
82
|
+
return recorded === null ? [null] : [recorded, null];
|
|
83
|
+
}
|
|
84
|
+
|
|
28
85
|
// The optimistic row, filed where it is about to land. Its own parent has to travel with it, or the
|
|
29
86
|
// plus on the moved row would still file into the folder it just left.
|
|
30
87
|
function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
@@ -45,13 +102,32 @@ function withParent(entry: TreeEntry, parentId: string | null): TreeEntry {
|
|
|
45
102
|
*/
|
|
46
103
|
export function moveVerdict(input: {
|
|
47
104
|
draggedId: string;
|
|
105
|
+
// ⚠️ Two answers to "where is this row", and since #429 they can differ — which is why both are
|
|
106
|
+
// asked (#446). `draggedParentId` is what the record says and what a move would overwrite;
|
|
107
|
+
// `draggedLevel` is the level the row is shown in.
|
|
108
|
+
//
|
|
109
|
+
// "Same place" has to be true for either, and for different reasons:
|
|
110
|
+
//
|
|
111
|
+
// - The record: dropping a row on the folder its own record already names writes the value that
|
|
112
|
+
// is already there. Nothing happens, and offering it says something will.
|
|
113
|
+
// - The level: a shared row reaches the root only through the share, and its record names the
|
|
114
|
+
// sharer's folder. Dropping it on the root looks like a no-op to the reader — but it would
|
|
115
|
+
// write `parentId = null` and lift the document OUT of the folder it was shared from. That is
|
|
116
|
+
// the opposite of nothing, done by somebody who was told nothing would happen.
|
|
117
|
+
//
|
|
118
|
+
// `draggedLevel` is optional because a surface without a tree has no level to name — the resource
|
|
119
|
+
// menu opens the same dialog from a title line. There the record is the only answer there is.
|
|
48
120
|
draggedParentId: string | null;
|
|
121
|
+
draggedLevel?: string | null | undefined;
|
|
49
122
|
targetId: string | null;
|
|
50
123
|
targetAncestors: ReadonlySet<string>;
|
|
51
124
|
}): MoveVerdict {
|
|
52
125
|
if (input.targetId === input.draggedId) return "self";
|
|
53
126
|
if (input.targetId !== null && input.targetAncestors.has(input.draggedId)) return "descendant";
|
|
54
127
|
if (input.targetId === input.draggedParentId) return "same-place";
|
|
128
|
+
if (input.draggedLevel !== undefined && input.targetId === input.draggedLevel) {
|
|
129
|
+
return "same-place";
|
|
130
|
+
}
|
|
55
131
|
return "ok";
|
|
56
132
|
}
|
|
57
133
|
|
|
@@ -98,11 +174,14 @@ export function moveErrorKey(error: unknown): string {
|
|
|
98
174
|
*/
|
|
99
175
|
export function MoveDialog({
|
|
100
176
|
entry,
|
|
177
|
+
level,
|
|
101
178
|
initial,
|
|
102
179
|
close,
|
|
103
180
|
submit,
|
|
104
181
|
}: {
|
|
105
182
|
entry: TreeEntry;
|
|
183
|
+
// `undefined` where no tree level was rendered — the verdict then rests on the record alone.
|
|
184
|
+
level: string | null | undefined;
|
|
106
185
|
initial: MoveDestination | null;
|
|
107
186
|
close(): void;
|
|
108
187
|
submit(destination: MoveDestination): void;
|
|
@@ -114,17 +193,20 @@ export function MoveDialog({
|
|
|
114
193
|
// being moved is never offered, so its subtree can never be entered in the first place.
|
|
115
194
|
const [path, setPath] = useState<MoveDestination[]>([]);
|
|
116
195
|
const here: MoveDestination = path.at(-1) ?? { id: null, title: i18n.t("tree.move.root") };
|
|
117
|
-
const
|
|
196
|
+
const children = useQuery({
|
|
118
197
|
queryKey: ["tree", here.id],
|
|
119
198
|
queryFn: async () => await data.listTreeChildren(here.id),
|
|
120
199
|
enabled: destination === null,
|
|
121
200
|
});
|
|
122
|
-
const folders = (
|
|
201
|
+
const folders = (children.data ?? []).filter(
|
|
123
202
|
(child) => child.kind === "folder" && child.id !== entry.id,
|
|
124
203
|
);
|
|
125
204
|
const verdict = moveVerdict({
|
|
126
205
|
draggedId: entry.id,
|
|
127
206
|
draggedParentId: parentOf(entry),
|
|
207
|
+
// Handed down by the caller, because the dialog is opened from two places and only one of them
|
|
208
|
+
// has a tree level to name (#446).
|
|
209
|
+
draggedLevel: level,
|
|
128
210
|
targetId: here.id,
|
|
129
211
|
targetAncestors: new Set(path.map((step) => step.id).filter((id) => id !== null)),
|
|
130
212
|
});
|
|
@@ -147,7 +229,7 @@ export function MoveDialog({
|
|
|
147
229
|
<p className="min-w-0 flex-1 truncate text-sm font-medium">{here.title}</p>
|
|
148
230
|
</div>
|
|
149
231
|
<ul className="max-h-56 space-y-1 overflow-y-auto">
|
|
150
|
-
{
|
|
232
|
+
{children.isPending ? (
|
|
151
233
|
<li className="px-2 py-1.5 text-sm text-muted-foreground">
|
|
152
234
|
{i18n.t("common.loading")}
|
|
153
235
|
</li>
|
|
@@ -236,7 +318,17 @@ export function useTreeMove({
|
|
|
236
318
|
// folder so the row is where the eye follows it. Nobody else has a tree to open.
|
|
237
319
|
onMoved?: ((destination: MoveDestination) => void) | undefined;
|
|
238
320
|
} = {}): {
|
|
239
|
-
|
|
321
|
+
// `level` is the one the row is standing in, and the caller says it or says `undefined` — the tree
|
|
322
|
+
// knows it from the level it rendered into, the title line has no tree at all. Guessing it here
|
|
323
|
+
// with `parentOf` is what filed a moved row into a cache level that does not exist (#446).
|
|
324
|
+
//
|
|
325
|
+
// ⚠️ `undefined` is not "root". It means the level is unknown, and it is handled by cleaning up
|
|
326
|
+
// BOTH levels such a row can stand in — see `levelsToClear`.
|
|
327
|
+
start(
|
|
328
|
+
entry: TreeEntry,
|
|
329
|
+
level: string | null | undefined,
|
|
330
|
+
destination: MoveDestination | null,
|
|
331
|
+
): void;
|
|
240
332
|
error: unknown;
|
|
241
333
|
dialog: React.ReactNode;
|
|
242
334
|
} {
|
|
@@ -244,6 +336,7 @@ export function useTreeMove({
|
|
|
244
336
|
const queryClient = useQueryClient();
|
|
245
337
|
const [moving, setMoving] = useState<{
|
|
246
338
|
entry: TreeEntry;
|
|
339
|
+
level: string | null | undefined;
|
|
247
340
|
initial: MoveDestination | null;
|
|
248
341
|
} | null>(null);
|
|
249
342
|
|
|
@@ -253,6 +346,7 @@ export function useTreeMove({
|
|
|
253
346
|
destination,
|
|
254
347
|
}: {
|
|
255
348
|
entry: TreeEntry;
|
|
349
|
+
level: string | null | undefined;
|
|
256
350
|
destination: MoveDestination;
|
|
257
351
|
}) => {
|
|
258
352
|
// `baseUpdatedAt` travels with the move: it is what turns a concurrent edit into a 409 the
|
|
@@ -273,20 +367,21 @@ export function useTreeMove({
|
|
|
273
367
|
});
|
|
274
368
|
}
|
|
275
369
|
},
|
|
276
|
-
onMutate: async ({ entry, destination }) => {
|
|
277
|
-
const
|
|
370
|
+
onMutate: async ({ entry, level, destination }) => {
|
|
371
|
+
const fromKeys = levelsToClear(entry, level).map(treeLevelKey);
|
|
278
372
|
const toKey = treeLevelKey(destination.id);
|
|
279
|
-
await Promise.all(
|
|
280
|
-
queryClient.cancelQueries({ queryKey
|
|
281
|
-
queryClient.cancelQueries({ queryKey: toKey }),
|
|
282
|
-
]);
|
|
283
|
-
const snapshot = [
|
|
284
|
-
[fromKey, queryClient.getQueryData<TreeEntry[]>(fromKey)],
|
|
285
|
-
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)],
|
|
286
|
-
] as const;
|
|
287
|
-
queryClient.setQueryData<TreeEntry[]>(fromKey, (current) =>
|
|
288
|
-
current?.filter((row) => row.id !== entry.id),
|
|
373
|
+
await Promise.all(
|
|
374
|
+
[...fromKeys, toKey].map(async (queryKey) => await queryClient.cancelQueries({ queryKey })),
|
|
289
375
|
);
|
|
376
|
+
const snapshot = [
|
|
377
|
+
...fromKeys.map((key) => [key, queryClient.getQueryData<TreeEntry[]>(key)] as const),
|
|
378
|
+
[toKey, queryClient.getQueryData<TreeEntry[]>(toKey)] as const,
|
|
379
|
+
];
|
|
380
|
+
for (const key of fromKeys) {
|
|
381
|
+
queryClient.setQueryData<TreeEntry[]>(key, (current) =>
|
|
382
|
+
current?.filter((row) => row.id !== entry.id),
|
|
383
|
+
);
|
|
384
|
+
}
|
|
290
385
|
// A level nobody has opened stays unloaded: writing one here would show a folder's contents
|
|
291
386
|
// that were never read.
|
|
292
387
|
queryClient.setQueryData<TreeEntry[]>(toKey, (current) =>
|
|
@@ -300,10 +395,11 @@ export function useTreeMove({
|
|
|
300
395
|
for (const [key, value] of context?.snapshot ?? []) queryClient.setQueryData(key, value);
|
|
301
396
|
},
|
|
302
397
|
onSuccess: (_result, { destination }) => onMoved?.(destination),
|
|
303
|
-
onSettled: async (_result, _error, { entry
|
|
398
|
+
onSettled: async (_result, _error, { entry }) => {
|
|
304
399
|
await Promise.all([
|
|
305
|
-
|
|
306
|
-
|
|
400
|
+
// Both ends of the move and the rows they hang under: a folder that just received its first
|
|
401
|
+
// row needs its arrow, and one that lost its last has to give it back (#519).
|
|
402
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
307
403
|
queryClient.invalidateQueries({
|
|
308
404
|
queryKey: [entry.type === "flow" ? "flows" : "node-graph"],
|
|
309
405
|
}),
|
|
@@ -313,19 +409,20 @@ export function useTreeMove({
|
|
|
313
409
|
});
|
|
314
410
|
|
|
315
411
|
return {
|
|
316
|
-
start(entry, destination) {
|
|
412
|
+
start(entry, level, destination) {
|
|
317
413
|
move.reset();
|
|
318
|
-
setMoving({ entry, initial: destination });
|
|
414
|
+
setMoving({ entry, level, initial: destination });
|
|
319
415
|
},
|
|
320
416
|
error: move.isError ? move.error : null,
|
|
321
417
|
dialog: moving ? (
|
|
322
418
|
<MoveDialog
|
|
323
419
|
entry={moving.entry}
|
|
420
|
+
level={moving.level}
|
|
324
421
|
initial={moving.initial}
|
|
325
422
|
close={() => setMoving(null)}
|
|
326
423
|
submit={(destination) => {
|
|
327
424
|
setMoving(null);
|
|
328
|
-
move.mutate({ entry: moving.entry, destination });
|
|
425
|
+
move.mutate({ entry: moving.entry, level: moving.level, destination });
|
|
329
426
|
}}
|
|
330
427
|
/>
|
|
331
428
|
) : null,
|
|
@@ -64,7 +64,7 @@ export function ViewToggle({ views = ["editor", "graph"] }: { views?: readonly I
|
|
|
64
64
|
search: view === "editor" ? rest : { ...rest, view },
|
|
65
65
|
});
|
|
66
66
|
}}
|
|
67
|
-
className="inline-flex size-8 items-center justify-center rounded-md
|
|
67
|
+
className="inline-flex size-8 items-center justify-center rounded-md outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
68
68
|
>
|
|
69
69
|
<Icon aria-hidden="true" className="size-4" />
|
|
70
70
|
</TooltipTrigger>
|
package/src/archive/archive.tsx
CHANGED
|
@@ -3,12 +3,13 @@ import type { Node } from "@anchrd/intel-contract/node";
|
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
4
|
import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
|
|
5
5
|
import { useState } from "react";
|
|
6
|
+
import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
|
|
6
7
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
7
8
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
8
9
|
import { kindIcons } from "@/kind-icon.ts";
|
|
9
10
|
import { Modal } from "@/modal/modal.tsx";
|
|
10
11
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
11
|
-
import {
|
|
12
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
12
13
|
|
|
13
14
|
// One archived thing, whichever side of the tree it came from. The two records stay apart
|
|
14
15
|
// everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
|
|
@@ -29,7 +30,6 @@ interface ArchivedEntry {
|
|
|
29
30
|
export function Archive() {
|
|
30
31
|
const { data } = useIntelRouterContext();
|
|
31
32
|
const i18n = useI18n();
|
|
32
|
-
const dateTime = useDateTime();
|
|
33
33
|
const queryClient = useQueryClient();
|
|
34
34
|
|
|
35
35
|
const archived = useQuery({
|
|
@@ -102,7 +102,7 @@ export function Archive() {
|
|
|
102
102
|
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
103
103
|
// The tree gets it back, so every level has to be asked again — the entry may sit
|
|
104
104
|
// anywhere, and the archive does not know where.
|
|
105
|
-
queryClient.invalidateQueries({ queryKey:
|
|
105
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
106
106
|
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
107
107
|
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
108
108
|
]);
|
|
@@ -127,7 +127,7 @@ export function Archive() {
|
|
|
127
127
|
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
128
128
|
// The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
|
|
129
129
|
// elsewhere: a reference to it no longer resolves afterwards.
|
|
130
|
-
queryClient.invalidateQueries({ queryKey:
|
|
130
|
+
queryClient.invalidateQueries({ queryKey: allTreeLevelsKey }),
|
|
131
131
|
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
132
132
|
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
133
133
|
]);
|
|
@@ -180,8 +180,9 @@ export function Archive() {
|
|
|
180
180
|
<span className="sr-only">{i18n.t(`node.kind.${entry.kind}`)}</span>
|
|
181
181
|
<span className="grid min-w-0 flex-1 leading-tight">
|
|
182
182
|
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
183
|
-
<span className="truncate text-xs text-muted-foreground">
|
|
184
|
-
{i18n.t("archive.
|
|
183
|
+
<span className="flex items-center gap-1 truncate text-xs text-muted-foreground">
|
|
184
|
+
{i18n.t("archive.archived")}
|
|
185
|
+
<RelativeTime value={entry.archivedAt} />
|
|
185
186
|
</span>
|
|
186
187
|
</span>
|
|
187
188
|
{/* Both actions stay permanently visible: the archive is the one place where getting
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
FlowPublishPreview,
|
|
10
10
|
FlowRequirements,
|
|
11
11
|
FlowValidation,
|
|
12
|
+
FlowVersionList,
|
|
12
13
|
ListFlowsInput,
|
|
13
14
|
PreviewFlowPublishInput,
|
|
14
15
|
PublishFlowInput,
|
|
@@ -441,6 +442,9 @@ export function createIntelDataProvider(
|
|
|
441
442
|
body: JSON.stringify(parsed),
|
|
442
443
|
});
|
|
443
444
|
},
|
|
445
|
+
async listFlowVersions(flowId) {
|
|
446
|
+
return await request(`/flows/${encodeURIComponent(flowId)}/versions`, FlowVersionList);
|
|
447
|
+
},
|
|
444
448
|
async previewFlowPublish(input) {
|
|
445
449
|
const parsed = PreviewFlowPublishInput.parse(input);
|
|
446
450
|
return await request(
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
FlowPublishPreview,
|
|
10
10
|
FlowRequirements,
|
|
11
11
|
FlowValidation,
|
|
12
|
+
FlowVersionList,
|
|
12
13
|
ListFlowsInput,
|
|
13
14
|
PreviewFlowPublishInput,
|
|
14
15
|
PublishFlowInput,
|
|
@@ -166,6 +167,7 @@ export interface IntelDataProvider {
|
|
|
166
167
|
// stops resolving as another flow's callee; its versions stay untouched.
|
|
167
168
|
archiveFlow(input: ArchiveFlowInput): Promise<Flow>;
|
|
168
169
|
saveFlow(input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
170
|
+
listFlowVersions(flowId: string): Promise<FlowVersionList>;
|
|
169
171
|
// Which version each sub-flow call will take once published, and which of them publishing
|
|
170
172
|
// freezes. Read before publishing, so the author agrees to the pins rather than discovering them.
|
|
171
173
|
previewFlowPublish(input: PreviewFlowPublishInput): Promise<FlowPublishPreview>;
|
|
@@ -5,7 +5,8 @@ import { useState } from "react";
|
|
|
5
5
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
6
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
7
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
9
|
+
import { TitleRowScrollArea } from "@/title-row/title-row.tsx";
|
|
9
10
|
|
|
10
11
|
// One page is what a person reads before deciding, not what a database can return. The server caps
|
|
11
12
|
// it at fifty; twenty is what fits on a screen without scrolling past the answer.
|
|
@@ -63,7 +64,7 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
63
64
|
{i18n.t("runs.onlyFailed")}
|
|
64
65
|
</label>
|
|
65
66
|
</div>
|
|
66
|
-
<
|
|
67
|
+
<TitleRowScrollArea className="min-h-0 flex-1 overflow-y-auto p-5">
|
|
67
68
|
{runs.isPending && (
|
|
68
69
|
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
69
70
|
)}
|
|
@@ -109,14 +110,13 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
109
110
|
{runs.isFetchingNextPage ? i18n.t("common.loading") : i18n.t("runs.more")}
|
|
110
111
|
</button>
|
|
111
112
|
)}
|
|
112
|
-
</
|
|
113
|
+
</TitleRowScrollArea>
|
|
113
114
|
</section>
|
|
114
115
|
);
|
|
115
116
|
}
|
|
116
117
|
|
|
117
118
|
function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; toggle(): void }) {
|
|
118
119
|
const i18n = useI18n();
|
|
119
|
-
const dateTime = useDateTime();
|
|
120
120
|
const failed = run.status === "failed";
|
|
121
121
|
const Chevron = open ? ChevronDown : ChevronRight;
|
|
122
122
|
return (
|
|
@@ -135,7 +135,7 @@ function RunRow({ run, open, toggle }: { run: FlowRunSummary; open: boolean; tog
|
|
|
135
135
|
>
|
|
136
136
|
{i18n.t(`runs.status.${run.status}`)}
|
|
137
137
|
</span>
|
|
138
|
-
<
|
|
138
|
+
<RelativeTime value={run.startedAt} />
|
|
139
139
|
<span className="text-muted-foreground">
|
|
140
140
|
{run.durationMs === null
|
|
141
141
|
? i18n.t("runs.stillRunning")
|