@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anchrd/intel-ui",
3
- "version": "0.8.3",
3
+ "version": "0.8.5",
4
4
  "type": "module",
5
5
  "license": "UNLICENSED",
6
6
  "repository": {
@@ -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: editor.document,
78
- markdown: await editor.blocksToMarkdownLossy(editor.document),
113
+ blocks,
114
+ markdown: await editor.blocksToMarkdownLossy(blocks),
79
115
  });
80
- return await data.saveKnowledge({
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: (saved) => {
89
- setDirty(false);
90
- onSaved(saved);
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
- setDirty(true);
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
- setDirty(true);
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") throw new Error("A flow cannot be archived");
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
- if (selected === id) await navigate({ to: "/knowledge", search: {} });
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
- {node ? (
264
- <>
265
- <DropdownMenuSeparator />
266
- <DropdownMenuItem
267
- onSelect={() => archive.mutate()}
268
- className="text-destructive focus:text-destructive"
269
- >
270
- <Archive aria-hidden="true" />
271
- {i18n.t("knowledge.archive")}
272
- </DropdownMenuItem>
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