@anchrd/intel-ui 0.32.0 → 0.34.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 +56 -5
- package/src/app/tree-move/tree-move.tsx +5 -5
- package/src/data/intel-data-provider/intel-data-provider.ts +11 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +9 -0
- 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 +219 -108
- package/src/graph-pane/graph-pane.tsx +9 -0
- package/src/i18n/de.json +24 -0
- package/src/i18n/en.json +24 -0
- package/src/i18n/es.json +24 -0
- package/src/node-editor/node-editor.tsx +24 -1
- package/src/nodes/nodes.tsx +31 -17
- package/src/resource-error.ts +33 -0
- package/src/resource-menu/resource-menu.tsx +37 -26
- package/src/table-columns/table-columns.tsx +379 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.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.21.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -150,7 +150,47 @@ export function AppTree() {
|
|
|
150
150
|
return calls.items.map((flow) => flowEntry(flow, openable.has(flow.id)));
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
|
|
153
|
+
/**
|
|
154
|
+
* The levels that still have a row to hang on — `expanded` minus everything the tree no longer
|
|
155
|
+
* shows (#521).
|
|
156
|
+
*
|
|
157
|
+
* ⚠️ `expanded` is only ever added to. A folder that was opened and has since left the tree —
|
|
158
|
+
* archived, moved away, its share revoked — used to keep its level in `useQueries` for the rest
|
|
159
|
+
* of the session, and every write from then on re-read a list nothing draws. Nothing was wrong on
|
|
160
|
+
* screen (`isOpen` needs the row, so the level was never rendered); the requests were simply paid
|
|
161
|
+
* for, and the number grew with the length of the SESSION rather than with what is on screen.
|
|
162
|
+
*
|
|
163
|
+
* ⚠️ Read from the cache rather than from `levels` below, and that is not a shortcut: the answer
|
|
164
|
+
* is needed BEFORE `useQueries` is told what to load, and deriving it from the results afterwards
|
|
165
|
+
* can only correct the list one render too late — by which time the request has been made.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ A walk down from the root, not "is the row anywhere": a level whose own row is gone must not
|
|
168
|
+
* keep vouching for the levels under it, or a whole opened branch would survive its top.
|
|
169
|
+
*
|
|
170
|
+
* ⚠️ A row is enough, and `openable` is deliberately NOT part of it although `isOpen` checks it.
|
|
171
|
+
* `reveal` opens a folder BEFORE the invalidation that gives it its arrow, so a folder that was
|
|
172
|
+
* empty a moment ago still says `openable: false` at that instant — judging on it would close the
|
|
173
|
+
* folder #519 exists to have opened. A refetch does not make a level unknown either: TanStack
|
|
174
|
+
* keeps the previous data while it refetches, so an ordinary invalidation closes nothing.
|
|
175
|
+
*/
|
|
176
|
+
const rowLevel = (entry: TreeEntry) =>
|
|
177
|
+
`${entry.kind === "folder" ? "folder" : "flow"}:${entry.id}`;
|
|
178
|
+
const rootLevel: Level = { id: null, type: "folder" };
|
|
179
|
+
const open: Level[] = [];
|
|
180
|
+
let frontier: Level[] = [rootLevel];
|
|
181
|
+
let waiting = [...expanded];
|
|
182
|
+
while (frontier.length > 0 && waiting.length > 0) {
|
|
183
|
+
const shown = new Set(
|
|
184
|
+
frontier.flatMap((level) =>
|
|
185
|
+
(queryClient.getQueryData<TreeEntry[]>(levelKey(level)) ?? []).map(rowLevel),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
frontier = waiting.filter((level) => shown.has(`${level.type}:${level.id}`));
|
|
189
|
+
waiting = waiting.filter((level) => !frontier.includes(level));
|
|
190
|
+
open.push(...frontier);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const parents: Level[] = [rootLevel, ...open];
|
|
154
194
|
const levels = useQueries({
|
|
155
195
|
queries: parents.map((parent) => ({
|
|
156
196
|
queryKey: levelKey(parent),
|
|
@@ -167,10 +207,19 @@ export function AppTree() {
|
|
|
167
207
|
levels[parents.findIndex((entry) => entry.id === level.id && entry.type === level.type)];
|
|
168
208
|
const root = levels[0];
|
|
169
209
|
|
|
170
|
-
|
|
210
|
+
// ⚠️ The list the reader opened is also trimmed here, and not only where it is read: `open`
|
|
211
|
+
// above keeps a departed level from being LOADED, but the entry itself would otherwise sit in
|
|
212
|
+
// `expanded` until the tab is closed — and a folder that comes back would come back open, which
|
|
213
|
+
// is a claim about a gesture nobody made in this tree. A gesture is where trimming is free: it
|
|
214
|
+
// is an event, so there is no render to loop through.
|
|
215
|
+
function toggle(level: Level & { id: string }, wanted: boolean) {
|
|
171
216
|
setExpanded((current) => {
|
|
172
|
-
const rest = current.filter(
|
|
173
|
-
|
|
217
|
+
const rest = current.filter(
|
|
218
|
+
(entry) =>
|
|
219
|
+
(entry.id !== level.id || entry.type !== level.type) &&
|
|
220
|
+
open.some((live) => live.id === entry.id && live.type === entry.type),
|
|
221
|
+
);
|
|
222
|
+
return wanted ? [...rest, level] : rest;
|
|
174
223
|
});
|
|
175
224
|
}
|
|
176
225
|
|
|
@@ -441,8 +490,10 @@ export function AppTree() {
|
|
|
441
490
|
// reader may see" (#59). The difference is an arrow that keeps its promise — before, it stood
|
|
442
491
|
// on every folder and every flow, empty ones included.
|
|
443
492
|
const expandable = entry.openable;
|
|
493
|
+
// ⚠️ `open`, not `expanded`: what is drawn and what is loaded answer the same question, or the
|
|
494
|
+
// tree would draw a level it never asked for (#521).
|
|
444
495
|
const isOpen =
|
|
445
|
-
expandable &&
|
|
496
|
+
expandable && open.some((live) => live.id === entry.id && live.type === level.type);
|
|
446
497
|
const Icon = isOpen && isFolder ? FolderOpen : kindIcons[entry.kind];
|
|
447
498
|
const area = entry.type === "flow" ? "/flows" : "/nodes";
|
|
448
499
|
const isActive = location.select === entry.id && location.pathname === area;
|
|
@@ -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.
|
|
@@ -66,6 +66,7 @@ import {
|
|
|
66
66
|
AppendTableRowsInput,
|
|
67
67
|
AppendTableRowsResult,
|
|
68
68
|
DefineTableInput,
|
|
69
|
+
RedefineTableInput,
|
|
69
70
|
} from "@anchrd/intel-contract/table";
|
|
70
71
|
import { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
|
|
71
72
|
import type { z } from "zod";
|
|
@@ -319,6 +320,16 @@ export function createIntelDataProvider(
|
|
|
319
320
|
{ method: "POST", body: JSON.stringify(parsed) },
|
|
320
321
|
);
|
|
321
322
|
},
|
|
323
|
+
// ⚠️ `PATCH`, where the two writes above are `POST`. The route is the same address as the
|
|
324
|
+
// header itself, and this changes it — `POST /nodes/:id/table` is the one that WRITES a header
|
|
325
|
+
// and refuses on a table that already has one.
|
|
326
|
+
async redefineTable(input) {
|
|
327
|
+
const parsed = RedefineTableInput.parse(input);
|
|
328
|
+
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}/table`, NodeTable, {
|
|
329
|
+
method: "PATCH",
|
|
330
|
+
body: JSON.stringify(parsed),
|
|
331
|
+
});
|
|
332
|
+
},
|
|
322
333
|
async listNodeVersions(nodeId) {
|
|
323
334
|
return await request(`/nodes/${encodeURIComponent(nodeId)}/versions`, NodeVersionList);
|
|
324
335
|
},
|
|
@@ -65,6 +65,7 @@ import type {
|
|
|
65
65
|
AppendTableRowsInput,
|
|
66
66
|
AppendTableRowsResult,
|
|
67
67
|
DefineTableInput,
|
|
68
|
+
RedefineTableInput,
|
|
68
69
|
} from "@anchrd/intel-contract/table";
|
|
69
70
|
import type { ToolCatalog, ToolServerCatalog } from "@anchrd/intel-contract/tool";
|
|
70
71
|
|
|
@@ -125,6 +126,14 @@ export interface IntelDataProvider {
|
|
|
125
126
|
getNodeTable(nodeId: string): Promise<NodeTable>;
|
|
126
127
|
defineTable(input: DefineTableInput): Promise<NodeTable>;
|
|
127
128
|
appendTableRows(input: AppendTableRowsInput): Promise<AppendTableRowsResult>;
|
|
129
|
+
// Changing the header of a table that already has one (#135). The mapping is explicit — every new
|
|
130
|
+
// column names the current one whose cells fill it, or `null` for an empty one — because a new
|
|
131
|
+
// header without it would reinterpret every stored row under names nobody matched to the old ones.
|
|
132
|
+
//
|
|
133
|
+
// ⚠️ `baseVersionId` is the `versionId` the mapping was written against, and the server refuses
|
|
134
|
+
// with `version_conflict` when the table has moved on. Not optional: a mapping onto a header
|
|
135
|
+
// somebody else has since changed would move the wrong cells, silently.
|
|
136
|
+
redefineTable(input: RedefineTableInput): Promise<NodeTable>;
|
|
128
137
|
|
|
129
138
|
// Walks the browser through the portal's OAuth flow without asking anybody anything: Gate is the
|
|
130
139
|
// identity provider Cloudflare Access consumes, so a signed-in person is already known there
|
|
@@ -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
|