@anchrd/intel-ui 0.8.3 → 0.8.5
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 +1 -1
- package/src/data/intel-data-provider/intel-data-provider.ts +11 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +4 -0
- package/src/i18n/en.json +1 -1
- package/src/knowledge-editor/knowledge-editor.tsx +55 -9
- package/src/resource-menu/resource-menu.tsx +32 -14
package/package.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AppendKnowledgeTableRowsInput,
|
|
3
3
|
AppendKnowledgeTableRowsResult,
|
|
4
|
+
ArchiveFlowInput,
|
|
4
5
|
ArchiveKnowledgeNodeInput,
|
|
5
6
|
CompleteFlowRunStepInput,
|
|
6
7
|
CreateFlowInput,
|
|
@@ -159,6 +160,9 @@ export function createIntelDataProvider(
|
|
|
159
160
|
const params = new URLSearchParams(
|
|
160
161
|
query.parentId === undefined ? {} : { parentId: query.parentId ?? "" },
|
|
161
162
|
);
|
|
163
|
+
// Same rule as `parentId`: only the asked-for case is sent. The default is the server's, and a
|
|
164
|
+
// provider that spells it out anyway would have to be changed whenever the server's is.
|
|
165
|
+
if (query.includeArchived) params.set("includeArchived", "true");
|
|
162
166
|
const search = params.toString();
|
|
163
167
|
return await request(`/flows${search ? `?${search}` : ""}`, FlowList);
|
|
164
168
|
}
|
|
@@ -366,6 +370,13 @@ export function createIntelDataProvider(
|
|
|
366
370
|
body: JSON.stringify(parsed),
|
|
367
371
|
});
|
|
368
372
|
},
|
|
373
|
+
async archiveFlow(input) {
|
|
374
|
+
const parsed = ArchiveFlowInput.parse(input);
|
|
375
|
+
return await request(`/flows/${encodeURIComponent(parsed.flowId)}/archive`, Flow, {
|
|
376
|
+
method: "POST",
|
|
377
|
+
body: JSON.stringify(parsed),
|
|
378
|
+
});
|
|
379
|
+
},
|
|
369
380
|
async saveFlow(input) {
|
|
370
381
|
const parsed = SaveFlowVersionInput.parse(input);
|
|
371
382
|
return await request(`/flows/${encodeURIComponent(parsed.flowId)}/versions`, FlowDocument, {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AppendKnowledgeTableRowsInput,
|
|
3
3
|
AppendKnowledgeTableRowsResult,
|
|
4
|
+
ArchiveFlowInput,
|
|
4
5
|
ArchiveKnowledgeNodeInput,
|
|
5
6
|
CompleteFlowRunStepInput,
|
|
6
7
|
CreateFlowInput,
|
|
@@ -117,6 +118,9 @@ export interface IntelDataProvider {
|
|
|
117
118
|
getFlowRequirements(flowId: string): Promise<FlowRequirements>;
|
|
118
119
|
createFlow(input: CreateFlowInput): Promise<Flow>;
|
|
119
120
|
updateFlow(input: UpdateFlowInput): Promise<Flow>;
|
|
121
|
+
// Archive a flow or take it back out. An archived flow leaves the tree, stops being startable and
|
|
122
|
+
// stops resolving as another flow's callee; its versions stay untouched.
|
|
123
|
+
archiveFlow(input: ArchiveFlowInput): Promise<Flow>;
|
|
120
124
|
saveFlow(input: SaveFlowVersionInput): Promise<FlowDocument>;
|
|
121
125
|
// Which version each sub-flow call will take once published, and which of them publishing
|
|
122
126
|
// freezes. Read before publishing, so the author agrees to the pins rather than discovering them.
|
package/src/i18n/en.json
CHANGED
|
@@ -72,6 +72,7 @@
|
|
|
72
72
|
"resource.menu": "More actions for {title}",
|
|
73
73
|
"resource.rename": "Rename",
|
|
74
74
|
"resource.renameTitle": "Rename {title}",
|
|
75
|
+
"resource.archive": "Archive",
|
|
75
76
|
"resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
|
|
76
77
|
"resource.forbidden": "It was not changed: you may not change this entry.",
|
|
77
78
|
"resource.failed": "The change was not saved. Reload the latest version and try again.",
|
|
@@ -114,7 +115,6 @@
|
|
|
114
115
|
"knowledge.shareUnreadableHint": "Nothing is blocked. A run of those flows will simply stop at that step for them.",
|
|
115
116
|
"knowledge.versions": "Version history",
|
|
116
117
|
"knowledge.version": "Version {sequence}",
|
|
117
|
-
"knowledge.archive": "Archive",
|
|
118
118
|
"view.showGraph": "Show this level as a graph",
|
|
119
119
|
"view.showEditor": "Back to the editor",
|
|
120
120
|
"view.showRuns": "Show this flow's runs",
|
|
@@ -9,7 +9,7 @@ import { filterSuggestionItems } from "@blocknote/core";
|
|
|
9
9
|
import { SuggestionMenuController, useCreateBlockNote } from "@blocknote/react";
|
|
10
10
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
11
11
|
import { Link2 } from "lucide-react";
|
|
12
|
-
import { useEffect, useMemo, useState } from "react";
|
|
12
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
13
13
|
import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
|
|
14
14
|
import { BlockNoteView, defaultSlashMenuItems } from "@/blocknote-view/blocknote-view.tsx";
|
|
15
15
|
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
@@ -34,6 +34,28 @@ function storedBlocks(content: string | null): IntelEditorPartialBlock[] | undef
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* What the editor holds, in a form two states can be compared by.
|
|
39
|
+
*
|
|
40
|
+
* ⚠️ This is what "unsaved" is answered with, and it deliberately is not "`onChange` fired". The
|
|
41
|
+
* editor changes for reasons that are not edits — loading the content is one (`replaceBlocks`
|
|
42
|
+
* below), and BlockNote normalizing a freshly mounted document is another. Neither is anything a
|
|
43
|
+
* reader did, and a warning that appears on every open is a warning nobody reads on the one day it
|
|
44
|
+
* is true. The canvas next door draws the same line with `editsNodes`/`editsEdges`.
|
|
45
|
+
*
|
|
46
|
+
* `id`s are part of it on purpose: BlockNote keeps them stable across edits within a session, and
|
|
47
|
+
* dropping them would call two different paragraphs equal whenever their text happens to match.
|
|
48
|
+
*
|
|
49
|
+
* ⚠️ It runs on every keystroke, and it walks the whole document to do so. That is affordable
|
|
50
|
+
* because `documentLinkIds` beside it already does, and because it is what buys the case a one-way
|
|
51
|
+
* flag cannot have: typing something and taking it back leaves nothing to save. There is no early
|
|
52
|
+
* exit — checking "already dirty, skip" is exactly what would lose that case. If documents ever
|
|
53
|
+
* grow far enough for this to be felt, the answer is to compare less often, not less carefully.
|
|
54
|
+
*/
|
|
55
|
+
function editorSnapshot(blocks: unknown): string {
|
|
56
|
+
return JSON.stringify(blocks);
|
|
57
|
+
}
|
|
58
|
+
|
|
37
59
|
export function KnowledgeEditor({
|
|
38
60
|
data,
|
|
39
61
|
document,
|
|
@@ -57,6 +79,13 @@ export function KnowledgeEditor({
|
|
|
57
79
|
// conditional that this repo's `exactOptionalPropertyTypes` defeats, so it hands back the
|
|
58
80
|
// default-schema editor even though it built Intel's. Same upstream seam as `BlockNoteView`.
|
|
59
81
|
}) as unknown as IntelEditor;
|
|
82
|
+
// The content as it stands on the server, in comparable form. Every question of "is there
|
|
83
|
+
// anything to save" is answered against this, and saving moves it forward.
|
|
84
|
+
//
|
|
85
|
+
// ⚠️ A ref, not state: it is read inside `onChange` on every keystroke and must be the current
|
|
86
|
+
// value there, not the one captured when that handler was created. Nothing renders from it — what
|
|
87
|
+
// renders is `dirty` — so there is nothing to re-render when it moves.
|
|
88
|
+
const savedSnapshot = useRef(editorSnapshot(editor.document));
|
|
60
89
|
const [dirty, setDirty] = useState(false);
|
|
61
90
|
const [picking, setPicking] = useState(false);
|
|
62
91
|
// Which documents the text links to right now. It is recomputed from the editor rather than from
|
|
@@ -69,25 +98,37 @@ export function KnowledgeEditor({
|
|
|
69
98
|
queryFn: () => data.resolveKnowledgeLinks({ nodeIds: linkedIds }),
|
|
70
99
|
enabled: linkedIds.length > 0,
|
|
71
100
|
});
|
|
101
|
+
// One place decides whether there is anything to save, so the answer cannot differ between the
|
|
102
|
+
// button, the guard and the save itself.
|
|
103
|
+
function settle(): void {
|
|
104
|
+
setDirty(editorSnapshot(editor.document) !== savedSnapshot.current);
|
|
105
|
+
}
|
|
106
|
+
|
|
72
107
|
const save = useMutation({
|
|
73
108
|
mutationFn: async () => {
|
|
109
|
+
const blocks = editor.document;
|
|
74
110
|
const payload = BlockNoteDocument.parse({
|
|
75
111
|
format: "blocknote",
|
|
76
112
|
schemaVersion: 1,
|
|
77
|
-
blocks
|
|
78
|
-
markdown: await editor.blocksToMarkdownLossy(
|
|
113
|
+
blocks,
|
|
114
|
+
markdown: await editor.blocksToMarkdownLossy(blocks),
|
|
79
115
|
});
|
|
80
|
-
|
|
116
|
+
const node = await data.saveKnowledge({
|
|
81
117
|
nodeId: document.node.id,
|
|
82
118
|
baseVersionId: document.version?.id ?? null,
|
|
83
119
|
content: JSON.stringify(payload),
|
|
84
120
|
mediaType: BlockNoteMediaType,
|
|
85
121
|
idempotencyKey: crypto.randomUUID(),
|
|
86
122
|
});
|
|
123
|
+
// ⚠️ The snapshot of what was SENT, not of what stands in the editor when the answer comes
|
|
124
|
+
// back. Saving crosses the network, and typing during it is normal — taking the state at
|
|
125
|
+
// arrival would call that typing saved and lose it at the next navigation.
|
|
126
|
+
return { node, snapshot: editorSnapshot(blocks) };
|
|
87
127
|
},
|
|
88
|
-
onSuccess: (
|
|
89
|
-
|
|
90
|
-
|
|
128
|
+
onSuccess: ({ node, snapshot }) => {
|
|
129
|
+
savedSnapshot.current = snapshot;
|
|
130
|
+
settle();
|
|
131
|
+
onSaved(node);
|
|
91
132
|
},
|
|
92
133
|
});
|
|
93
134
|
|
|
@@ -95,6 +136,11 @@ export function KnowledgeEditor({
|
|
|
95
136
|
if (!document.content || initialBlocks) return;
|
|
96
137
|
const blocks = editor.tryParseMarkdownToBlocks(document.content);
|
|
97
138
|
editor.replaceBlocks(editor.document, blocks);
|
|
139
|
+
// ⚠️ Loading the content IS a change to the editor, and `onChange` reports it as one. This is
|
|
140
|
+
// the line that keeps a document nobody has touched from asking to be saved: what was just
|
|
141
|
+
// loaded is by definition what is stored.
|
|
142
|
+
savedSnapshot.current = editorSnapshot(editor.document);
|
|
143
|
+
setDirty(false);
|
|
98
144
|
}, [document.content, editor, initialBlocks]);
|
|
99
145
|
|
|
100
146
|
function insertLink(nodeId: string): void {
|
|
@@ -105,7 +151,7 @@ export function KnowledgeEditor({
|
|
|
105
151
|
" ",
|
|
106
152
|
]);
|
|
107
153
|
setPicking(false);
|
|
108
|
-
|
|
154
|
+
settle();
|
|
109
155
|
setLinkedIds(documentLinkIds(editor.document));
|
|
110
156
|
}
|
|
111
157
|
|
|
@@ -135,7 +181,7 @@ export function KnowledgeEditor({
|
|
|
135
181
|
<BlockNoteView
|
|
136
182
|
editor={editor}
|
|
137
183
|
onChange={() => {
|
|
138
|
-
|
|
184
|
+
settle();
|
|
139
185
|
setLinkedIds(documentLinkIds(editor.document));
|
|
140
186
|
}}
|
|
141
187
|
>
|
|
@@ -125,6 +125,7 @@ export function ResourceMenu({
|
|
|
125
125
|
const [sharing, setSharing] = useState(false);
|
|
126
126
|
const [linksOpen, setLinksOpen] = useState(false);
|
|
127
127
|
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
128
|
+
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
128
129
|
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
129
130
|
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
130
131
|
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
@@ -189,9 +190,20 @@ export function ResourceMenu({
|
|
|
189
190
|
},
|
|
190
191
|
});
|
|
191
192
|
|
|
193
|
+
// Both records take the same conflict route as a rename: `baseUpdatedAt` from the row that was on
|
|
194
|
+
// screen, so archiving something somebody else has just changed is refused rather than silently
|
|
195
|
+
// applied to a different state than the one that was read.
|
|
192
196
|
const archive = useMutation({
|
|
193
197
|
mutationFn: async () => {
|
|
194
|
-
if (target.type === "flow")
|
|
198
|
+
if (target.type === "flow") {
|
|
199
|
+
await data.archiveFlow({
|
|
200
|
+
flowId: target.flow.id,
|
|
201
|
+
baseUpdatedAt: target.flow.updatedAt,
|
|
202
|
+
archived: true,
|
|
203
|
+
idempotencyKey: crypto.randomUUID(),
|
|
204
|
+
});
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
195
207
|
await data.archiveKnowledge({
|
|
196
208
|
nodeId: target.node.id,
|
|
197
209
|
baseUpdatedAt: target.node.updatedAt,
|
|
@@ -203,7 +215,14 @@ export function ResourceMenu({
|
|
|
203
215
|
await refresh();
|
|
204
216
|
// The row is gone from the tree. If it was also what the screen was showing, the screen has
|
|
205
217
|
// nothing left to show either.
|
|
206
|
-
|
|
218
|
+
//
|
|
219
|
+
// ⚠️ It drops the selection where it stands rather than always landing on /knowledge: a flow
|
|
220
|
+
// archived from the flow screen would otherwise throw the reader onto another screen for no
|
|
221
|
+
// reason they could see. A flow reached through the shared tree stays on /knowledge, which is
|
|
222
|
+
// where it was being read.
|
|
223
|
+
if (selected === id) {
|
|
224
|
+
await navigate({ to: pathname === "/flows" ? "/flows" : "/knowledge", search: {} });
|
|
225
|
+
}
|
|
207
226
|
},
|
|
208
227
|
});
|
|
209
228
|
|
|
@@ -260,18 +279,17 @@ export function ResourceMenu({
|
|
|
260
279
|
{i18n.t("knowledge.share")}
|
|
261
280
|
</DropdownMenuItem>
|
|
262
281
|
) : null}
|
|
263
|
-
{
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
) : null}
|
|
282
|
+
{/* A flow is archived like a document (issue 109). It keeps its versions and its runs; what it
|
|
283
|
+
loses is reach — it leaves the tree, cannot be started, and stops resolving as another
|
|
284
|
+
flow's callee. */}
|
|
285
|
+
<DropdownMenuSeparator />
|
|
286
|
+
<DropdownMenuItem
|
|
287
|
+
onSelect={() => archive.mutate()}
|
|
288
|
+
className="text-destructive focus:text-destructive"
|
|
289
|
+
>
|
|
290
|
+
<Archive aria-hidden="true" />
|
|
291
|
+
{i18n.t("resource.archive")}
|
|
292
|
+
</DropdownMenuItem>
|
|
275
293
|
</DropdownMenuContent>
|
|
276
294
|
</DropdownMenu>
|
|
277
295
|
{/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
|