@anchrd/intel-ui 0.33.0 → 0.35.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 +100 -11
- package/src/app/tree-move/tree-move.tsx +5 -5
- package/src/archive/archive.tsx +83 -7
- package/src/data/intel-data-provider/intel-data-provider.ts +12 -1
- package/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/src/entry-picker/entry-picker.tsx +27 -4
- package/src/flow-runs/flow-runs.tsx +9 -1
- package/src/flows/flows.tsx +264 -109
- package/src/graph-pane/graph-pane.tsx +9 -0
- package/src/i18n/de.json +34 -17
- package/src/i18n/en.json +34 -17
- package/src/i18n/es.json +34 -17
- package/src/node-editor/node-editor.tsx +24 -1
- package/src/nodes/nodes.tsx +31 -17
- package/src/resource-menu/resource-menu.tsx +84 -24
- package/src/tools/tools.tsx +5 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.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.22.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
Plus,
|
|
11
11
|
Upload,
|
|
12
12
|
} from "lucide-react";
|
|
13
|
-
import { useRef, useState } from "react";
|
|
13
|
+
import { useId, useRef, useState } from "react";
|
|
14
14
|
import {
|
|
15
15
|
allTreeLevelsKey,
|
|
16
16
|
type MoveDestination,
|
|
@@ -112,6 +112,10 @@ export function AppTree() {
|
|
|
112
112
|
const uploadInput = useRef<HTMLInputElement>(null);
|
|
113
113
|
const [dragged, setDragged] = useState<Carried | null>(null);
|
|
114
114
|
const draggedRef = useRef<Carried | null>(null);
|
|
115
|
+
// The id of the one sentence every movable row is described by (#590). Minted rather than written
|
|
116
|
+
// down: two trees on one page would otherwise share a literal id, and the second would describe
|
|
117
|
+
// its rows with the first one's element.
|
|
118
|
+
const movableId = useId();
|
|
115
119
|
// Which target the pointer is over: `undefined` for none, `null` for the root strip, an id for a
|
|
116
120
|
// folder row. Three answers, because "the root" and "nothing" are not the same drop.
|
|
117
121
|
const [over, setOver] = useState<string | null | undefined>(undefined);
|
|
@@ -150,7 +154,47 @@ export function AppTree() {
|
|
|
150
154
|
return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
|
|
151
155
|
}
|
|
152
156
|
|
|
153
|
-
|
|
157
|
+
/**
|
|
158
|
+
* The levels that still have a row to hang on — `expanded` minus everything the tree no longer
|
|
159
|
+
* shows (#521).
|
|
160
|
+
*
|
|
161
|
+
* ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
|
|
162
|
+
* archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
|
|
163
|
+
* of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
|
|
164
|
+
* screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
|
|
165
|
+
* for, and the number grew with the length of the SESSION rather than with what is on screen.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
|
|
168
|
+
* is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
|
|
169
|
+
* can only correct the list one render too late — by which time the request has been made.
|
|
170
|
+
*
|
|
171
|
+
* ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
|
|
172
|
+
* keep vouching for the levels under it, or a whole opened branch would survive its top.
|
|
173
|
+
*
|
|
174
|
+
* ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
|
|
175
|
+
* `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
|
|
176
|
+
* empty a moment ago still says `openable: false` at that instant — judging on it would close the
|
|
177
|
+
* folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
|
|
178
|
+
* keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
|
|
179
|
+
*/
|
|
180
|
+
const rowLevel = (entry: TreeEntry) =>
|
|
181
|
+
`${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
|
|
182
|
+
const rootLevel: Level = { id: null, type: "folder" };
|
|
183
|
+
const open: Level[] = [];
|
|
184
|
+
let frontier: Level[] = [rootLevel];
|
|
185
|
+
let waiting = [...expanded];
|
|
186
|
+
while (frontier.length > 0 && waiting.length > 0) {
|
|
187
|
+
const shown = new Set(
|
|
188
|
+
frontier.flatMap((level) =>
|
|
189
|
+
(queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
|
|
190
|
+
),
|
|
191
|
+
);
|
|
192
|
+
frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
|
|
193
|
+
waiting = waiting.filter((level) => !frontier.includes(level));
|
|
194
|
+
open.push(...frontier);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const parents: Level[] = [rootLevel, ...open];
|
|
154
198
|
const levels = useQueries({
|
|
155
199
|
queries: parents.map((parent) => ({
|
|
156
200
|
queryKey: levelKey(parent),
|
|
@@ -167,10 +211,19 @@ export function AppTree() {
|
|
|
167
211
|
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
168
212
|
const root = levels[0];
|
|
169
213
|
|
|
170
|
-
|
|
214
|
+
// ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
|
|
215
|
+
// above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
|
|
216
|
+
// `expanded` until the tab is closed — and a folder that comes back would come back open, which
|
|
217
|
+
// is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
|
|
218
|
+
// is an event, so there is no render to loop through.
|
|
219
|
+
function toggle(level: Level & { id: string }, wanted: boolean) {
|
|
171
220
|
setExpanded((current) => {
|
|
172
|
-
const rest = current.filter(
|
|
173
|
-
|
|
221
|
+
const rest = current.filter(
|
|
222
|
+
(entry) =>
|
|
223
|
+
(entry.id !== level.id || entry.type !== level.type) &&
|
|
224
|
+
open.some((live) => live.id === entry.id && live.type === entry.type),
|
|
225
|
+
);
|
|
226
|
+
return wanted ? [...rest, level] : rest;
|
|
174
227
|
});
|
|
175
228
|
}
|
|
176
229
|
|
|
@@ -441,8 +494,10 @@ export function AppTree() {
|
|
|
441
494
|
// reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
|
|
442
495
|
// on every folder and every flow, empty ones included.
|
|
443
496
|
const expandable = entry.openable;
|
|
497
|
+
// ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
|
|
498
|
+
// tree would draw a level it never asked for (#521).
|
|
444
499
|
const isOpen =
|
|
445
|
-
expandable &&
|
|
500
|
+
expandable && open.some((live) => live.id === entry.id && live.type === level.type);
|
|
446
501
|
const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
|
|
447
502
|
const area = entry.type === "flow" ? "/flows" : "/nodes";
|
|
448
503
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
@@ -575,6 +630,21 @@ export function AppTree() {
|
|
|
575
630
|
putting `draggable` on this form control is precisely the Safari failure from #483. */}
|
|
576
631
|
<SidebarMenuButton
|
|
577
632
|
isActive={isActive}
|
|
633
|
+
/* ⚠️ What says the row is movable BEFORE anything touches it (#590). Until now only
|
|
634
|
+
`cursor-grab` said it, and that is a statement addressed to a pointer that is already
|
|
635
|
+
on the row — nobody navigating by keyboard, by touch or by ear ever received it, and
|
|
636
|
+
a way nobody sees is a way nobody uses. That is how the dead gesture of #483 stayed
|
|
637
|
+
dead for months without a single report.
|
|
638
|
+
|
|
639
|
+
⚠️ A DESCRIPTION and not part of the name: the name is the title, and twenty rows
|
|
640
|
+
each announcing an instruction would bury it. It is also one shared element rather
|
|
641
|
+
than one per row — the sentence is the same for every movable row, and a copy per row
|
|
642
|
+
is a copy per row to keep in step.
|
|
643
|
+
|
|
644
|
+
⚠️ Absent on a derived row, and that is the whole discrimination: what a flow calls
|
|
645
|
+
has no `parent_id` to rewrite and is not draggable (`draggable={!derived}`), so
|
|
646
|
+
claiming it were movable would send somebody after a gesture that is refused. */
|
|
647
|
+
aria-describedby={derived ? undefined : movableId}
|
|
578
648
|
data-drop={
|
|
579
649
|
dragged === null ? undefined : isDragged ? "dragged" : (drop?.verdict ?? "none")
|
|
580
650
|
}
|
|
@@ -622,8 +692,30 @@ export function AppTree() {
|
|
|
622
692
|
|
|
623
693
|
return (
|
|
624
694
|
<SidebarGroup className="min-h-0 flex-1 overflow-y-auto">
|
|
695
|
+
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
696
|
+
named for assistive technology instead. */}
|
|
697
|
+
{renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
|
|
698
|
+
{/* ⚠️ The sentence every movable row points at (#590). It names BOTH ways on purpose: the
|
|
699
|
+
drag for whoever has a pointer, and the entry in the title line for whoever has not —
|
|
700
|
+
`resource.move` is the same word it carries there. Naming only the drag would describe a
|
|
701
|
+
gesture that keyboard and touch do not have, which is the state this ticket found.
|
|
702
|
+
|
|
703
|
+
⚠️ It is rendered AFTER the rows and it is `sr-only`, so it takes no space and moves
|
|
704
|
+
nothing. Anything a row could be pushed down by is the abort of #483 all over again — and
|
|
705
|
+
an element that exists from the first render pushes nothing in any case. */}
|
|
706
|
+
<span id={movableId} className="sr-only">
|
|
707
|
+
{i18n.t("tree.move.rowHint")}
|
|
708
|
+
</span>
|
|
625
709
|
{/* The root has no row to drop on, so while something is being carried it gets one. It is the
|
|
626
|
-
only way back out of a folder, and it appears exactly when it can be used.
|
|
710
|
+
only way back out of a folder, and it appears exactly when it can be used.
|
|
711
|
+
⚠️ BELOW the tree, and that is not a matter of taste (#483). It used to stand above it, so
|
|
712
|
+
the moment a drag began every row moved down by its height — including the row being
|
|
713
|
+
dragged. Chromium ABORTS a drag whose source shifts under it: `dragstart` fired, `dragend`
|
|
714
|
+
followed six milliseconds later, and no `drag`, `dragover` or `drop` ever came. The hand
|
|
715
|
+
appeared and nothing moved, which is exactly what the ticket describes. Below the tree the
|
|
716
|
+
strip pushes nothing that sits above it, so the source stays where the gesture started.
|
|
717
|
+
Measured in a real Chromium: with the strip above, the gesture dies; with it here, the same
|
|
718
|
+
drag reaches the drop and opens the move dialog. */}
|
|
627
719
|
{rootDrop.verdict === undefined || dragged === null ? null : (
|
|
628
720
|
<button
|
|
629
721
|
type="button"
|
|
@@ -640,7 +732,7 @@ export function AppTree() {
|
|
|
640
732
|
});
|
|
641
733
|
}}
|
|
642
734
|
data-drop={rootDrop.verdict}
|
|
643
|
-
className={`
|
|
735
|
+
className={`mt-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 ${
|
|
644
736
|
rootDrop.verdict === "ok"
|
|
645
737
|
? over === null
|
|
646
738
|
? "bg-sidebar-accent ring-2 ring-sidebar-ring"
|
|
@@ -652,9 +744,6 @@ export function AppTree() {
|
|
|
652
744
|
{i18n.t("tree.move.dropRoot")}
|
|
653
745
|
</button>
|
|
654
746
|
)}
|
|
655
|
-
{/* The tree is the navigation and carries no heading of its own (ADR-0004); the list is
|
|
656
|
-
named for assistive technology instead. */}
|
|
657
|
-
{renderLevel({ id: null, type: "folder" }, new Set(), i18n.t("tree.label"))}
|
|
658
747
|
{create.isError || upload.isError || bundleImport.isError ? (
|
|
659
748
|
<p role="alert" className="px-2 py-1.5 text-sm text-destructive">
|
|
660
749
|
{bundleImport.isError
|
|
@@ -47,11 +47,11 @@ export function treeLevelKey(parentId: string | null): readonly unknown[] {
|
|
|
47
47
|
* level is not always the one the record names: since #429 a shared row also stands at the root
|
|
48
48
|
* (`levelsToClear`).
|
|
49
49
|
*
|
|
50
|
-
* ⚠️ What it costs is one request per
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
50
|
+
* ⚠️ What it costs is one request per level the reader has OPEN — bounded by the screen, not by how
|
|
51
|
+
* long the tab has been sitting there. `app-tree.tsx` loads a level only while the row it hangs on
|
|
52
|
+
* still stands in the level above it, so a folder that was opened and has since left the tree —
|
|
53
|
+
* archived, moved away, its share revoked — takes its query with it rather than being re-read by
|
|
54
|
+
* every write for the rest of the session.
|
|
55
55
|
*
|
|
56
56
|
* The same prefix is what `archive.tsx` has always used for the same reason: a row can come back
|
|
57
57
|
* anywhere, and the screen writing it does not know where.
|
package/src/archive/archive.tsx
CHANGED
|
@@ -5,8 +5,10 @@ import { ArchiveRestore, LoaderCircle, Trash2 } from "lucide-react";
|
|
|
5
5
|
import { useState } from "react";
|
|
6
6
|
import { allTreeLevelsKey } from "@/app/tree-move/tree-move.tsx";
|
|
7
7
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
|
|
8
|
+
import { IntelRequestError } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
8
9
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
9
10
|
import { kindIcons } from "@/kind-icon.ts";
|
|
11
|
+
import { cn } from "@/lib/utils.ts";
|
|
10
12
|
import { Modal } from "@/modal/modal.tsx";
|
|
11
13
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
12
14
|
import { RelativeTime } from "@/time/relative-time.tsx";
|
|
@@ -27,6 +29,70 @@ interface ArchivedEntry {
|
|
|
27
29
|
previewPurge?(): Promise<{ inboundLinks: number; totalItems: number }>;
|
|
28
30
|
}
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The refusal of a permanent deletion — and the one place the reader can actually reach it.
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ #601. It used to stand on the page only, and the confirmation dialog that produces it **stays
|
|
36
|
+
* open** on a refusal while marking everything outside itself `aria-hidden` (`Modal` → react-aria's
|
|
37
|
+
* `ModalOverlay`). Behind that the sentence was not merely awkward to get at: for a screen reader it
|
|
38
|
+
* was not there at all, and it announced nothing. Since #593 this is the sentence carrying the NAMES
|
|
39
|
+
* of the flows that still call the flow — the one thing that makes the refusal actionable, and the
|
|
40
|
+
* reason the ticket was built.
|
|
41
|
+
*
|
|
42
|
+
* ⚠️ It is rendered in exactly ONE of two places, never both: inside the dialog while that is open,
|
|
43
|
+
* on the page once it is closed. The second half is not a leftover — the reader closes the dialog to
|
|
44
|
+
* go and change the callers, and the sentence has to still be there when they come back. Two copies
|
|
45
|
+
* would be two announcements of one answer, which is the `#445` mistake in a new costume.
|
|
46
|
+
*
|
|
47
|
+
* ⚠️ This is NOT the `#430` class and does not go through `RefusalNotice`. That one answers a
|
|
48
|
+
* refused QUERY — a `403`/`404` that leaves a whole pane with nothing to show, told as a centered
|
|
49
|
+
* sentence without a retry. This is a refused mutation that carries data (`409` with `callers`),
|
|
50
|
+
* belongs beside the button that caused it, and its problem was never the wording.
|
|
51
|
+
*/
|
|
52
|
+
function PurgeRefusal({ error, className }: { error: unknown; className?: string }) {
|
|
53
|
+
const i18n = useI18n();
|
|
54
|
+
/**
|
|
55
|
+
* ⚠️ Deleting a flow for good is refused with `409 flow_in_use_by_flow` while published flows
|
|
56
|
+
* still call it (`flows.ts`, `purge`), and the CALLERS are what makes that refusal actionable —
|
|
57
|
+
* "reload and try again" is advice for a flow nobody calls, and this one is called.
|
|
58
|
+
*
|
|
59
|
+
* ⚠️ Since #593 they arrive as data — the flows this reader may see by name, the rest as a count
|
|
60
|
+
* — so the sentence is written here, in the reader's language, exactly as the share dialog writes
|
|
61
|
+
* the folder's half of the same rule (#448). The server still decides WHICH may be named; that is
|
|
62
|
+
* an authorization answer and nothing on this side recomputes it.
|
|
63
|
+
*/
|
|
64
|
+
const requestError = error instanceof IntelRequestError ? error : null;
|
|
65
|
+
const inUse = requestError?.code === "flow_in_use_by_flow" ? requestError : null;
|
|
66
|
+
return (
|
|
67
|
+
<div role="alert" className={cn("space-y-1 text-sm text-destructive", className)}>
|
|
68
|
+
{inUse ? (
|
|
69
|
+
<>
|
|
70
|
+
<p>{i18n.t("archive.purgeInUse")}</p>
|
|
71
|
+
{/* An API older than #593 sends no `callers`, and then its own sentence is the only place
|
|
72
|
+
the titles exist at all. Showing it beats showing nothing. */}
|
|
73
|
+
{inUse.callers === null ? (
|
|
74
|
+
<p className="text-xs">{inUse.message}</p>
|
|
75
|
+
) : (
|
|
76
|
+
<p className="text-xs">
|
|
77
|
+
{inUse.callers.titles.length > 0
|
|
78
|
+
? i18n.t("archive.purgeInUseCallers", {
|
|
79
|
+
titles: inUse.callers.titles.join(", "),
|
|
80
|
+
})
|
|
81
|
+
: i18n.t("archive.purgeInUseCallersHidden", { count: inUse.callers.hidden })}
|
|
82
|
+
{inUse.callers.titles.length > 0 && inUse.callers.hidden > 0
|
|
83
|
+
? ` ${i18n.t("archive.purgeInUseCallersMore", { count: inUse.callers.hidden })}`
|
|
84
|
+
: ""}
|
|
85
|
+
{` ${i18n.t("archive.purgeInUseHint")}`}
|
|
86
|
+
</p>
|
|
87
|
+
)}
|
|
88
|
+
</>
|
|
89
|
+
) : (
|
|
90
|
+
<p>{i18n.t("archive.purgeFailed")}</p>
|
|
91
|
+
)}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
30
96
|
export function Archive() {
|
|
31
97
|
const { data } = useIntelRouterContext();
|
|
32
98
|
const i18n = useI18n();
|
|
@@ -153,12 +219,11 @@ export function Archive() {
|
|
|
153
219
|
{i18n.t("archive.restoreFailed")}
|
|
154
220
|
</p>
|
|
155
221
|
) : null}
|
|
156
|
-
{/* ⚠️
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
</p>
|
|
222
|
+
{/* ⚠️ On the page only while no dialog is open (#601). The refusal carries the reason — a
|
|
223
|
+
flow still calls this one — and it has to survive the dialog it came from; but as long as
|
|
224
|
+
that dialog stands, the page is `aria-hidden` and this is the copy nobody can reach. */}
|
|
225
|
+
{purge.isError && !confirming ? (
|
|
226
|
+
<PurgeRefusal error={purge.error} className="mb-4" />
|
|
162
227
|
) : null}
|
|
163
228
|
{archived.data?.length === 0 ? (
|
|
164
229
|
<p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
|
|
@@ -225,7 +290,14 @@ export function Archive() {
|
|
|
225
290
|
<button
|
|
226
291
|
type="button"
|
|
227
292
|
disabled={purge.isPending}
|
|
228
|
-
onClick={() =>
|
|
293
|
+
onClick={() => {
|
|
294
|
+
// ⚠️ The last refusal is cleared as the NEXT dialog opens, not when one closes:
|
|
295
|
+
// the sentence has to outlive the dialog it came from (#601), and it must not be
|
|
296
|
+
// standing in a dialog about a different entry — that would answer a question
|
|
297
|
+
// nobody asked, about the wrong thing.
|
|
298
|
+
purge.reset();
|
|
299
|
+
setConfirming(entry);
|
|
300
|
+
}}
|
|
229
301
|
aria-label={i18n.t("archive.purge", { title: entry.title })}
|
|
230
302
|
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
231
303
|
>
|
|
@@ -263,6 +335,10 @@ export function Archive() {
|
|
|
263
335
|
</p>
|
|
264
336
|
) : null}
|
|
265
337
|
<p className="mt-2 text-sm text-muted-foreground">{i18n.t("archive.purge.grants")}</p>
|
|
338
|
+
{/* ⚠️ Inside the dialog, because the dialog stays open on a refusal and hides the page
|
|
339
|
+
behind it from the accessibility tree entirely (#601). Above the buttons rather than
|
|
340
|
+
below them: the answer belongs between the reason and the button that asks again. */}
|
|
341
|
+
{purge.isError ? <PurgeRefusal error={purge.error} className="mt-4" /> : null}
|
|
266
342
|
<div className="mt-6 flex justify-end gap-2">
|
|
267
343
|
<button
|
|
268
344
|
type="button"
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ProblemDetails, SessionUser } from "@anchrd/intel-contract";
|
|
1
|
+
import { type NamedOrCounted, ProblemDetails, SessionUser } from "@anchrd/intel-contract";
|
|
2
2
|
import { BundleImportResult } from "@anchrd/intel-contract/bundle";
|
|
3
3
|
import {
|
|
4
4
|
ArchiveFlowInput,
|
|
@@ -78,10 +78,20 @@ import type { IntelDataProvider, TreeEntry } from "./intel-data-provider.types.t
|
|
|
78
78
|
// else changed it" — cannot do so from `detail`, which is a sentence the server is free to
|
|
79
79
|
// reword. The code is the contract (`ProblemDetails.code`); the message stays what it was.
|
|
80
80
|
export class IntelRequestError extends Error {
|
|
81
|
+
/**
|
|
82
|
+
* ⚠️ `callers` is the structured half of the two refusals that name flows — `folder_execute_in_use`
|
|
83
|
+
* (#448) and `flow_in_use_by_flow` (#593): the flows this reader may see, by name, and a count of
|
|
84
|
+
* the ones they may not. It is `null` for every other failure, and a screen that reads it has to
|
|
85
|
+
* say which code it read it for — the field is only meaningful beside its own.
|
|
86
|
+
*
|
|
87
|
+
* `message` still holds the server's finished English sentence. It is no longer what the dialog
|
|
88
|
+
* shows; it is what a log or a report has when nobody translated anything.
|
|
89
|
+
*/
|
|
81
90
|
constructor(
|
|
82
91
|
public readonly status: number,
|
|
83
92
|
public readonly code: string | null,
|
|
84
93
|
message: string,
|
|
94
|
+
public readonly callers: NamedOrCounted | null = null,
|
|
85
95
|
) {
|
|
86
96
|
super(message);
|
|
87
97
|
this.name = "IntelRequestError";
|
|
@@ -162,6 +172,7 @@ export function createIntelDataProvider(
|
|
|
162
172
|
parsed.success
|
|
163
173
|
? (parsed.data.detail ?? parsed.data.title)
|
|
164
174
|
: `Intel responded with ${result.status}`,
|
|
175
|
+
parsed.success ? (parsed.data.callers ?? null) : null,
|
|
165
176
|
);
|
|
166
177
|
}
|
|
167
178
|
return result;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Refusal } from "@/data/request-refusal/request-refusal.ts";
|
|
2
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* What a refusal is told with, on every surface that has one.
|
|
6
|
+
*
|
|
7
|
+
* ⚠️ A refusal is not a failure, and the difference is the whole of #430. Somebody whose share was
|
|
8
|
+
* revoked was left in front of "Loading…" — the worst answer of all, because it says the program is
|
|
9
|
+
* still working. They get a sentence and no retry button: the answer was final, and a button that
|
|
10
|
+
* changes nothing is an invitation to keep waiting.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ One sentence for `404`, whichever of its two reasons applies. Intel answers the same status
|
|
13
|
+
* for "no such node" and "not for you" on purpose — `requireVisible` in
|
|
14
|
+
* `packages/api/src/nodes/nodes.ts` — and a screen that told them apart would undo that from the
|
|
15
|
+
* other side.
|
|
16
|
+
*
|
|
17
|
+
* ⚠️ One function rather than a block per branch, because two answers to one question on one
|
|
18
|
+
* surface IS #445. A screen's halves — the empty level, the selected item, the view beside it — are
|
|
19
|
+
* asked the same thing, and copies drift.
|
|
20
|
+
*
|
|
21
|
+
* ⚠️ **Why this became shared here and not one copy earlier.** `nodes.tsx` (#445) and `flows.tsx`
|
|
22
|
+
* (#557) each carried an identical version, and #557 wrote its reason on the second one: this
|
|
23
|
+
* repository builds an abstraction after the third time it hurts, not the second. #568 is the third
|
|
24
|
+
* time — `?view=graph` and `?view=runs` are the third and fourth surface asking for the same
|
|
25
|
+
* sentence — so what would have been a third copy is this file. The rule did not bend; it was met.
|
|
26
|
+
*/
|
|
27
|
+
export function RefusalNotice({ refusal }: { refusal: Refusal }) {
|
|
28
|
+
const i18n = useI18n();
|
|
29
|
+
return (
|
|
30
|
+
<div role="status" className="grid flex-1 place-items-center p-8 text-center text-sm">
|
|
31
|
+
<p className="max-w-sm text-muted-foreground">
|
|
32
|
+
{i18n.t(refusal === "no-permission" ? "common.noPermission" : "common.noAccess")}
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
);
|
|
36
|
+
}
|
|
@@ -92,6 +92,24 @@ export function EntryPicker({
|
|
|
92
92
|
}, [wanted]);
|
|
93
93
|
const eligible = useMemo(() => nodes.filter(pickable), [nodes, pickable]);
|
|
94
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Whether an entry stands on the picker's start level.
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ The root is not `parentId === null` but the upper edge of what the reader may see — the same
|
|
99
|
+
* rule #429 gave the server in `levelPredicate`, at the second surface (#447). A recipient whose
|
|
100
|
+
* only access is a NESTED share carries a `parentId` pointing at a folder that is not in the list
|
|
101
|
+
* at all: under the old rule their start level was empty and STAYED empty, because the folder
|
|
102
|
+
* that would open it can never be clicked. Only searching reached them.
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ Nothing is added by this. The list is what `getNodeGraph()` — and `listFlows()` where flows
|
|
105
|
+
* were asked for — answered, and the answer is already the authorized one. What changes is only
|
|
106
|
+
* which level an entry stands on.
|
|
107
|
+
*/
|
|
108
|
+
const rooted = useMemo(() => {
|
|
109
|
+
const present = new Set(nodes.map((node) => node.id));
|
|
110
|
+
return (entry: PickerEntry) => entry.parentId === null || !present.has(entry.parentId);
|
|
111
|
+
}, [nodes]);
|
|
112
|
+
|
|
95
113
|
// Searching looks at every eligible node wherever it sits; browsing looks at one level. The two
|
|
96
114
|
// are the same list seen two ways, which is why a result can be picked from either.
|
|
97
115
|
const searching = query.trim().length > 0;
|
|
@@ -100,8 +118,10 @@ export function EntryPicker({
|
|
|
100
118
|
const needle = query.trim().toLowerCase();
|
|
101
119
|
return eligible.filter((node) => node.title.toLowerCase().includes(needle)).slice(0, 50);
|
|
102
120
|
}
|
|
103
|
-
return nodes.filter((node) =>
|
|
104
|
-
|
|
121
|
+
return nodes.filter((node) =>
|
|
122
|
+
openFolder === null ? rooted(node) : node.parentId === openFolder,
|
|
123
|
+
);
|
|
124
|
+
}, [searching, query, eligible, nodes, openFolder, rooted]);
|
|
105
125
|
|
|
106
126
|
// The way back up, as the chain of folders that leads to the open one.
|
|
107
127
|
const trail = useMemo(() => {
|
|
@@ -111,10 +131,13 @@ export function EntryPicker({
|
|
|
111
131
|
const folder = nodes.find((node) => node.id === current);
|
|
112
132
|
if (!folder) break;
|
|
113
133
|
chain.unshift(folder);
|
|
114
|
-
|
|
134
|
+
// ⚠️ The chain ends where the start level begins, and by the same rule that decides it. A
|
|
135
|
+
// folder at the upper edge has an ancestor the reader cannot see; walking into it would look
|
|
136
|
+
// for a level that does not exist here, and the "top" button above leads to this one.
|
|
137
|
+
current = rooted(folder) ? null : folder.parentId;
|
|
115
138
|
}
|
|
116
139
|
return chain;
|
|
117
|
-
}, [openFolder, nodes]);
|
|
140
|
+
}, [openFolder, nodes, rooted]);
|
|
118
141
|
|
|
119
142
|
const chosen = nodes.find((node) => node.id === value) ?? null;
|
|
120
143
|
|
|
@@ -2,6 +2,8 @@ import type { FlowRunSummary } from "@anchrd/intel-contract/flow-run";
|
|
|
2
2
|
import { useInfiniteQuery, useQuery } from "@tanstack/react-query";
|
|
3
3
|
import { ChevronDown, ChevronRight, CornerDownRight } from "lucide-react";
|
|
4
4
|
import { useState } from "react";
|
|
5
|
+
import { RefusalNotice } from "@/data/request-refusal/refusal-notice.tsx";
|
|
6
|
+
import { refusalOf } from "@/data/request-refusal/request-refusal.ts";
|
|
5
7
|
import type { I18n } from "@/i18n/i18n.types.ts";
|
|
6
8
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
7
9
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
@@ -45,6 +47,11 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
45
47
|
getNextPageParam: (last) => last.nextCursor,
|
|
46
48
|
});
|
|
47
49
|
const items = runs.data?.pages.flatMap((page) => page.items) ?? [];
|
|
50
|
+
// ⚠️ This list's own refusal, told here rather than upstream (#568). The screen above answers
|
|
51
|
+
// whether the flow may be opened; `listFlowRuns` answers whether its history may be read, and it
|
|
52
|
+
// can refuse on its own. Everything that is not a refusal keeps the sentence and the button
|
|
53
|
+
// below — a `502` really can answer differently next time.
|
|
54
|
+
const refusal = refusalOf(runs.error);
|
|
48
55
|
|
|
49
56
|
return (
|
|
50
57
|
<section aria-label={i18n.t("runs.title")} className="flex min-h-0 flex-1 flex-col">
|
|
@@ -68,7 +75,8 @@ export function FlowRuns({ flowId }: { flowId: string }) {
|
|
|
68
75
|
{runs.isPending && (
|
|
69
76
|
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
70
77
|
)}
|
|
71
|
-
{
|
|
78
|
+
{refusal && <RefusalNotice refusal={refusal} />}
|
|
79
|
+
{runs.isError && !refusal && (
|
|
72
80
|
<div role="alert" className="space-y-3 text-sm">
|
|
73
81
|
<p className="text-destructive">{i18n.t("runs.failedToLoad")}</p>
|
|
74
82
|
<button
|