@anchrd/intel-ui 0.4.0 → 0.6.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 +1 -1
- package/src/app/action-slot/action-slot.tsx +27 -0
- package/src/app/app-sidebar/app-sidebar.tsx +39 -24
- package/src/app/app-tree/app-tree.tsx +332 -60
- package/src/app/app.tsx +31 -5
- package/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +2 -1
- package/src/app/tree-move/tree-move.tsx +331 -0
- package/src/app/user-footer/user-footer.tsx +73 -46
- package/src/app/view-toggle/view-toggle.tsx +77 -0
- package/src/blocknote-view/blocknote-view.tsx +19 -2
- package/src/branding/favicon.default.svg +2 -2
- package/src/branding/favicon.svg +2 -2
- package/src/components/ui/dropdown-menu.tsx +78 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +135 -57
- package/src/data/intel-data-provider/intel-data-provider.types.ts +51 -13
- package/src/document-link/document-link.tsx +132 -0
- package/src/flow-runs/flow-runs.tsx +225 -0
- package/src/flows/flows.tsx +665 -271
- package/src/flows/node-icon/node-icon.ts +28 -0
- package/src/flows/node-palette/node-palette.tsx +200 -0
- package/src/flows/node-palette/node-palette.types.ts +15 -0
- package/src/graph-pane/graph-pane.tsx +44 -0
- package/src/i18n/en.json +144 -29
- package/src/knowledge/knowledge.tsx +91 -367
- package/src/knowledge-editor/knowledge-editor.tsx +169 -21
- package/src/knowledge-graph/knowledge-graph.ts +26 -24
- package/src/knowledge-graph/knowledge-graph.tsx +33 -24
- package/src/knowledge-table/knowledge-table.tsx +141 -0
- package/src/main.tsx +2 -2
- package/src/resource-menu/resource-menu.tsx +615 -0
- package/src/router/selection-search.ts +27 -3
- package/src/save-button/save-button.tsx +103 -0
- package/src/styles.css +37 -0
- package/src/theme/theme.ts +24 -0
- package/src/title-row/title-row.tsx +49 -0
- package/src/tools/tools.tsx +57 -38
- package/src/app/header-actions/header-actions.tsx +0 -15
|
@@ -0,0 +1,615 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ContextPolicy,
|
|
3
|
+
Flow,
|
|
4
|
+
KnowledgeNode,
|
|
5
|
+
ResourceVerb,
|
|
6
|
+
UnreadableKnowledge,
|
|
7
|
+
} from "@anchrd/intel-contract";
|
|
8
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
9
|
+
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
10
|
+
import { Archive, CornerLeftUp, Ellipsis, Link2, Pencil, Share2, Trash2 } from "lucide-react";
|
|
11
|
+
import type * as React from "react";
|
|
12
|
+
import { useState } from "react";
|
|
13
|
+
import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
|
|
14
|
+
import {
|
|
15
|
+
DropdownMenu,
|
|
16
|
+
DropdownMenuContent,
|
|
17
|
+
DropdownMenuItem,
|
|
18
|
+
DropdownMenuRadioGroup,
|
|
19
|
+
DropdownMenuRadioItem,
|
|
20
|
+
DropdownMenuSeparator,
|
|
21
|
+
DropdownMenuSub,
|
|
22
|
+
DropdownMenuSubContent,
|
|
23
|
+
DropdownMenuSubTrigger,
|
|
24
|
+
DropdownMenuTrigger,
|
|
25
|
+
} from "@/components/ui/dropdown-menu";
|
|
26
|
+
import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
27
|
+
import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
28
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
29
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
30
|
+
import { selectedFrom } from "@/router/selection-search.ts";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* What a resource's own actions are, at the place the resource stands (#24).
|
|
34
|
+
*
|
|
35
|
+
* ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
|
|
36
|
+
* attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
|
|
37
|
+
* 240px column and made the sidebar something to read buttons in rather than titles. Everything the
|
|
38
|
+
* row menu could do is reached here — including moving, which is why this component owns the folder
|
|
39
|
+
* picker rather than being handed one.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode, a flow
|
|
42
|
+
* is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
|
|
43
|
+
* still promises something is there.
|
|
44
|
+
*/
|
|
45
|
+
export type ResourceTarget =
|
|
46
|
+
| { type: "knowledge"; node: KnowledgeNode }
|
|
47
|
+
| { type: "flow"; flow: Flow };
|
|
48
|
+
|
|
49
|
+
// The same thing as a tree row, because that is what a move works on: a row with a place in the
|
|
50
|
+
// shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
|
|
51
|
+
// and never a second source of truth.
|
|
52
|
+
function entryOf(target: ResourceTarget): TreeEntry {
|
|
53
|
+
return target.type === "flow"
|
|
54
|
+
? flowEntry(target.flow)
|
|
55
|
+
: {
|
|
56
|
+
type: "knowledge",
|
|
57
|
+
id: target.node.id,
|
|
58
|
+
title: target.node.title,
|
|
59
|
+
kind: target.node.kind,
|
|
60
|
+
node: target.node,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function idOf(target: ResourceTarget): string {
|
|
65
|
+
return target.type === "knowledge" ? target.node.id : target.flow.id;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function titleOf(target: ResourceTarget): string {
|
|
69
|
+
return target.type === "knowledge" ? target.node.title : target.flow.title;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function parentOfTarget(target: ResourceTarget): string | null {
|
|
73
|
+
return target.type === "knowledge" ? target.node.parentId : target.flow.parentId;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ⚠️ The same reading `moveErrorKey` does for a move, for the changes this menu makes. A conflict is
|
|
77
|
+
// the one that must not be swallowed: `baseUpdatedAt` turns a parallel edit into a refusal, and a
|
|
78
|
+
// refusal nobody words is indistinguishable from a change that went through.
|
|
79
|
+
export function resourceErrorKey(error: unknown): string {
|
|
80
|
+
const code =
|
|
81
|
+
typeof error === "object" && error !== null && "code" in error
|
|
82
|
+
? String((error as { code: unknown }).code)
|
|
83
|
+
: null;
|
|
84
|
+
switch (code) {
|
|
85
|
+
case "update_conflict":
|
|
86
|
+
case "flow_update_conflict":
|
|
87
|
+
return "resource.conflict";
|
|
88
|
+
case "knowledge_forbidden":
|
|
89
|
+
case "flow_edit_forbidden":
|
|
90
|
+
return "resource.forbidden";
|
|
91
|
+
default:
|
|
92
|
+
return "resource.failed";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explicit"];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* The three-dot menu.
|
|
100
|
+
*
|
|
101
|
+
* `variant` is only how the trigger is painted. `title` is the one in use: a plain icon button at
|
|
102
|
+
* the end of a title line, put there by `TitleRow` and by nobody else, so it is always the last
|
|
103
|
+
* thing in that line (#53). `row` is the sidebar form — hidden until the row is hovered or something
|
|
104
|
+
* in it takes focus — and is unused since #58; it is kept because the trigger's two paintings are
|
|
105
|
+
* the only difference between the two places, and re-deriving it would be the harder half.
|
|
106
|
+
*/
|
|
107
|
+
export function ResourceMenu({
|
|
108
|
+
target,
|
|
109
|
+
variant,
|
|
110
|
+
}: {
|
|
111
|
+
target: ResourceTarget;
|
|
112
|
+
variant: "row" | "title";
|
|
113
|
+
}) {
|
|
114
|
+
const { data, i18n } = useIntelRouterContext();
|
|
115
|
+
const queryClient = useQueryClient();
|
|
116
|
+
const navigate = useNavigate();
|
|
117
|
+
const [renaming, setRenaming] = useState(false);
|
|
118
|
+
const [sharing, setSharing] = useState(false);
|
|
119
|
+
const [linksOpen, setLinksOpen] = useState(false);
|
|
120
|
+
const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
|
|
121
|
+
// ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
|
|
122
|
+
// the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
|
|
123
|
+
// the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
|
|
124
|
+
const move = useTreeMove();
|
|
125
|
+
|
|
126
|
+
const id = idOf(target);
|
|
127
|
+
const title = titleOf(target);
|
|
128
|
+
const node = target.type === "knowledge" ? target.node : null;
|
|
129
|
+
// A folder holds things; it is not itself retrieved, and nothing is written into it that could
|
|
130
|
+
// link anywhere. Both entries are therefore absent on one rather than offered and inert.
|
|
131
|
+
const retrievable = node !== null && node.kind !== "folder";
|
|
132
|
+
const linkable = retrievable;
|
|
133
|
+
|
|
134
|
+
// Everything a change here can make stale. The row sits in one level of the tree, the open screen
|
|
135
|
+
// reads the record, and the flat collections behind the search, the link picker and the relation
|
|
136
|
+
// view all carry the title as well — a rename that only reached the tree would leave yesterday's
|
|
137
|
+
// name standing on three other screens.
|
|
138
|
+
async function refresh(): Promise<void> {
|
|
139
|
+
await Promise.all([
|
|
140
|
+
queryClient.invalidateQueries({ queryKey: ["tree", parentOfTarget(target)] }),
|
|
141
|
+
queryClient.invalidateQueries({
|
|
142
|
+
queryKey: target.type === "flow" ? ["flow", id] : ["knowledge", id],
|
|
143
|
+
}),
|
|
144
|
+
queryClient.invalidateQueries({
|
|
145
|
+
queryKey: [target.type === "flow" ? "flows" : "knowledge-graph"],
|
|
146
|
+
}),
|
|
147
|
+
queryClient.invalidateQueries({ queryKey: ["relation-graph"] }),
|
|
148
|
+
]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ⚠️ `baseUpdatedAt` is not optional decoration. Renaming takes the same conflict route as every
|
|
152
|
+
// other change, or two people renaming at once end with one of them silently overwritten.
|
|
153
|
+
const rename = useMutation({
|
|
154
|
+
mutationFn: async (next: string) => {
|
|
155
|
+
if (target.type === "flow") {
|
|
156
|
+
await data.updateFlow({
|
|
157
|
+
flowId: target.flow.id,
|
|
158
|
+
baseUpdatedAt: target.flow.updatedAt,
|
|
159
|
+
title: next,
|
|
160
|
+
idempotencyKey: crypto.randomUUID(),
|
|
161
|
+
});
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
await data.updateKnowledge({
|
|
165
|
+
nodeId: target.node.id,
|
|
166
|
+
baseUpdatedAt: target.node.updatedAt,
|
|
167
|
+
title: next,
|
|
168
|
+
idempotencyKey: crypto.randomUUID(),
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
onSuccess: async () => {
|
|
172
|
+
setRenaming(false);
|
|
173
|
+
await refresh();
|
|
174
|
+
},
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const archive = useMutation({
|
|
178
|
+
mutationFn: async () => {
|
|
179
|
+
if (target.type === "flow") throw new Error("A flow cannot be archived");
|
|
180
|
+
await data.archiveKnowledge({
|
|
181
|
+
nodeId: target.node.id,
|
|
182
|
+
baseUpdatedAt: target.node.updatedAt,
|
|
183
|
+
archived: true,
|
|
184
|
+
idempotencyKey: crypto.randomUUID(),
|
|
185
|
+
});
|
|
186
|
+
},
|
|
187
|
+
onSuccess: async () => {
|
|
188
|
+
await refresh();
|
|
189
|
+
// The row is gone from the tree. If it was also what the screen was showing, the screen has
|
|
190
|
+
// nothing left to show either.
|
|
191
|
+
if (selected === id) await navigate({ to: "/knowledge", search: {} });
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const setContextPolicy = useMutation({
|
|
196
|
+
mutationFn: async (contextPolicy: ContextPolicy) => {
|
|
197
|
+
if (target.type === "flow") throw new Error("A flow has no retrieval mode");
|
|
198
|
+
await data.updateKnowledge({
|
|
199
|
+
nodeId: target.node.id,
|
|
200
|
+
baseUpdatedAt: target.node.updatedAt,
|
|
201
|
+
contextPolicy,
|
|
202
|
+
idempotencyKey: crypto.randomUUID(),
|
|
203
|
+
});
|
|
204
|
+
},
|
|
205
|
+
onSuccess: refresh,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const failure = rename.isError
|
|
209
|
+
? null // the rename dialog words its own refusal, beside the field that caused it
|
|
210
|
+
: archive.isError
|
|
211
|
+
? archive.error
|
|
212
|
+
: setContextPolicy.isError
|
|
213
|
+
? setContextPolicy.error
|
|
214
|
+
: undefined;
|
|
215
|
+
|
|
216
|
+
return (
|
|
217
|
+
<>
|
|
218
|
+
<DropdownMenu>
|
|
219
|
+
<DropdownMenuTrigger
|
|
220
|
+
data-resource-menu=""
|
|
221
|
+
aria-label={i18n.t("resource.menu", { title })}
|
|
222
|
+
className={
|
|
223
|
+
variant === "row"
|
|
224
|
+
? "shrink-0 rounded-md p-1 text-sidebar-foreground outline-none transition-opacity hover:bg-sidebar-accent-foreground/10 focus-visible:opacity-100 focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:opacity-100 group-focus-within/row:opacity-100 group-hover/row:opacity-100 md:opacity-0"
|
|
225
|
+
: "inline-flex size-8 shrink-0 items-center justify-center rounded-md border bg-background outline-none hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring"
|
|
226
|
+
}
|
|
227
|
+
>
|
|
228
|
+
<Ellipsis aria-hidden="true" className="size-4" />
|
|
229
|
+
</DropdownMenuTrigger>
|
|
230
|
+
<DropdownMenuContent align="start" className="w-52">
|
|
231
|
+
<DropdownMenuItem onSelect={() => setRenaming(true)}>
|
|
232
|
+
<Pencil aria-hidden="true" />
|
|
233
|
+
{i18n.t("resource.rename")}
|
|
234
|
+
</DropdownMenuItem>
|
|
235
|
+
<DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
|
|
236
|
+
<CornerLeftUp aria-hidden="true" />
|
|
237
|
+
{i18n.t("tree.move.action")}
|
|
238
|
+
</DropdownMenuItem>
|
|
239
|
+
{retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
|
|
240
|
+
{/* ⚠️ A submenu with a checked value, not an embedded `select`. A form control inside a
|
|
241
|
+
menu takes the keyboard away from the menu that contains it, and the current value is
|
|
242
|
+
then only readable by opening a second widget. */}
|
|
243
|
+
{retrievable && node ? (
|
|
244
|
+
<DropdownMenuSub>
|
|
245
|
+
<DropdownMenuSubTrigger>{i18n.t("knowledge.contextPolicy")}</DropdownMenuSubTrigger>
|
|
246
|
+
<DropdownMenuSubContent>
|
|
247
|
+
<DropdownMenuRadioGroup
|
|
248
|
+
value={node.contextPolicy}
|
|
249
|
+
onValueChange={(value) => setContextPolicy.mutate(value as ContextPolicy)}
|
|
250
|
+
>
|
|
251
|
+
{contextPolicies.map((policy) => (
|
|
252
|
+
<DropdownMenuRadioItem key={policy} value={policy}>
|
|
253
|
+
{i18n.t(`knowledge.context.${policy}`)}
|
|
254
|
+
</DropdownMenuRadioItem>
|
|
255
|
+
))}
|
|
256
|
+
</DropdownMenuRadioGroup>
|
|
257
|
+
</DropdownMenuSubContent>
|
|
258
|
+
</DropdownMenuSub>
|
|
259
|
+
) : null}
|
|
260
|
+
{linkable ? (
|
|
261
|
+
<DropdownMenuItem onSelect={() => setLinksOpen(true)}>
|
|
262
|
+
<Link2 aria-hidden="true" />
|
|
263
|
+
{i18n.t("knowledge.links")}
|
|
264
|
+
</DropdownMenuItem>
|
|
265
|
+
) : null}
|
|
266
|
+
{/* A flow has no share of its own: it is reached through the folder it is filed in, and a
|
|
267
|
+
narrower grant beside the folder's would break the rule that a flow only calls flows in
|
|
268
|
+
its own subtree (ADR-0004 §2). */}
|
|
269
|
+
{node ? (
|
|
270
|
+
<DropdownMenuItem onSelect={() => setSharing(true)}>
|
|
271
|
+
<Share2 aria-hidden="true" />
|
|
272
|
+
{i18n.t("knowledge.share")}
|
|
273
|
+
</DropdownMenuItem>
|
|
274
|
+
) : null}
|
|
275
|
+
{node ? (
|
|
276
|
+
<>
|
|
277
|
+
<DropdownMenuSeparator />
|
|
278
|
+
<DropdownMenuItem
|
|
279
|
+
onSelect={() => archive.mutate()}
|
|
280
|
+
className="text-destructive focus:text-destructive"
|
|
281
|
+
>
|
|
282
|
+
<Archive aria-hidden="true" />
|
|
283
|
+
{i18n.t("knowledge.archive")}
|
|
284
|
+
</DropdownMenuItem>
|
|
285
|
+
</>
|
|
286
|
+
) : null}
|
|
287
|
+
</DropdownMenuContent>
|
|
288
|
+
</DropdownMenu>
|
|
289
|
+
{/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
|
|
290
|
+
the screen instead, where it is read whatever the title line asked for. */}
|
|
291
|
+
{failure !== undefined && failure !== null ? (
|
|
292
|
+
<MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
|
|
293
|
+
) : null}
|
|
294
|
+
{/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
|
|
295
|
+
`resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
|
|
296
|
+
among the changes above, and one shared sentence would leave the reader guessing. */}
|
|
297
|
+
{move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
|
|
298
|
+
{move.dialog}
|
|
299
|
+
{renaming ? (
|
|
300
|
+
<RenameDialog
|
|
301
|
+
title={title}
|
|
302
|
+
pending={rename.isPending}
|
|
303
|
+
error={rename.isError ? rename.error : null}
|
|
304
|
+
close={() => {
|
|
305
|
+
rename.reset();
|
|
306
|
+
setRenaming(false);
|
|
307
|
+
}}
|
|
308
|
+
submit={(next) => rename.mutate(next)}
|
|
309
|
+
/>
|
|
310
|
+
) : null}
|
|
311
|
+
{sharing && node ? <ShareKnowledge node={node} close={() => setSharing(false)} /> : null}
|
|
312
|
+
{linksOpen && node ? <KnowledgeLinks node={node} close={() => setLinksOpen(false)} /> : null}
|
|
313
|
+
</>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
|
|
318
|
+
function MenuFailure({ children }: { children: React.ReactNode }) {
|
|
319
|
+
return (
|
|
320
|
+
<p
|
|
321
|
+
role="alert"
|
|
322
|
+
className="fixed inset-x-0 bottom-5 z-50 mx-auto w-fit max-w-md rounded-lg border border-destructive/30 bg-card px-4 py-3 text-sm text-destructive shadow-xl"
|
|
323
|
+
>
|
|
324
|
+
{children}
|
|
325
|
+
</p>
|
|
326
|
+
);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function RenameDialog({
|
|
330
|
+
title,
|
|
331
|
+
pending,
|
|
332
|
+
error,
|
|
333
|
+
close,
|
|
334
|
+
submit,
|
|
335
|
+
}: {
|
|
336
|
+
title: string;
|
|
337
|
+
pending: boolean;
|
|
338
|
+
error: unknown;
|
|
339
|
+
close(): void;
|
|
340
|
+
submit(title: string): void;
|
|
341
|
+
}) {
|
|
342
|
+
const { i18n } = useIntelRouterContext();
|
|
343
|
+
const [next, setNext] = useState(title);
|
|
344
|
+
const trimmed = next.trim();
|
|
345
|
+
return (
|
|
346
|
+
<Modal title={i18n.t("resource.renameTitle", { title })} close={close}>
|
|
347
|
+
<form
|
|
348
|
+
className="space-y-4"
|
|
349
|
+
onSubmit={(event) => {
|
|
350
|
+
event.preventDefault();
|
|
351
|
+
if (!pending && trimmed.length > 0 && trimmed !== title) submit(trimmed);
|
|
352
|
+
}}
|
|
353
|
+
>
|
|
354
|
+
{error ? (
|
|
355
|
+
<p role="alert" className="text-sm text-destructive">
|
|
356
|
+
{i18n.t(resourceErrorKey(error))}
|
|
357
|
+
</p>
|
|
358
|
+
) : null}
|
|
359
|
+
<label className="block text-sm font-medium">
|
|
360
|
+
{i18n.t("common.title")}
|
|
361
|
+
<input
|
|
362
|
+
required
|
|
363
|
+
// biome-ignore lint/a11y/noAutofocus: the dialog opens to take this one value
|
|
364
|
+
autoFocus
|
|
365
|
+
value={next}
|
|
366
|
+
onChange={(event) => setNext(event.target.value)}
|
|
367
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
368
|
+
/>
|
|
369
|
+
</label>
|
|
370
|
+
<button
|
|
371
|
+
type="submit"
|
|
372
|
+
disabled={pending || trimmed.length === 0 || trimmed === title}
|
|
373
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
374
|
+
>
|
|
375
|
+
{pending ? i18n.t("common.saving") : i18n.t("common.save")}
|
|
376
|
+
</button>
|
|
377
|
+
</form>
|
|
378
|
+
</Modal>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* What links to this document and what it links to — as a reading, not as a form (#41).
|
|
384
|
+
*
|
|
385
|
+
* ⚠️ There is nothing to create here any more. A relationship is written in the text of the
|
|
386
|
+
* document it belongs to, and this shows the result. The backlinks are the half that could never be
|
|
387
|
+
* written here anyway: "who points at me" is not something the author of this document decides.
|
|
388
|
+
*
|
|
389
|
+
* ⚠️ Every row is a link whose two ends this reader may see — the server filters the list through
|
|
390
|
+
* the same predicate as every other read. A relationship reaching into a part of the tree they may
|
|
391
|
+
* not enter is absent rather than shown without a name.
|
|
392
|
+
*/
|
|
393
|
+
function KnowledgeLinks({ node, close }: { node: KnowledgeNode; close(): void }) {
|
|
394
|
+
const { data, i18n } = useIntelRouterContext();
|
|
395
|
+
const navigate = useNavigate();
|
|
396
|
+
const links = useQuery({
|
|
397
|
+
queryKey: ["knowledge-links", node.id],
|
|
398
|
+
queryFn: () => data.listKnowledgeLinks(node.id),
|
|
399
|
+
});
|
|
400
|
+
// The dialog needs the names of the documents at the other end, and the graph is the one
|
|
401
|
+
// authorized list of them the UI can ask for without walking the whole tree again. It is asked
|
|
402
|
+
// for here rather than by the screen, because here is where it is read.
|
|
403
|
+
const knowledgeGraph = useQuery({
|
|
404
|
+
queryKey: ["knowledge-graph"],
|
|
405
|
+
queryFn: () => data.getKnowledgeGraph(),
|
|
406
|
+
});
|
|
407
|
+
const titles = new Map(
|
|
408
|
+
(knowledgeGraph.data?.nodes ?? []).map((candidate) => [candidate.id, candidate.title]),
|
|
409
|
+
);
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<Modal title={i18n.t("knowledge.links")} close={close}>
|
|
413
|
+
<p className="mb-4 text-sm text-muted-foreground">{i18n.t("knowledge.linksHelp")}</p>
|
|
414
|
+
<ul className="max-h-72 space-y-2 overflow-y-auto">
|
|
415
|
+
{links.data?.items.map((link) => {
|
|
416
|
+
const outgoing = link.sourceNodeId === node.id;
|
|
417
|
+
const otherId = outgoing ? link.targetNodeId : link.sourceNodeId;
|
|
418
|
+
return (
|
|
419
|
+
<li key={link.id}>
|
|
420
|
+
<button
|
|
421
|
+
type="button"
|
|
422
|
+
onClick={() => {
|
|
423
|
+
close();
|
|
424
|
+
void navigate({ to: "/knowledge", search: { select: otherId } });
|
|
425
|
+
}}
|
|
426
|
+
className="flex w-full items-center gap-3 rounded-md border p-3 text-left outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
427
|
+
>
|
|
428
|
+
<span className="min-w-0 text-sm">
|
|
429
|
+
<span className="block truncate font-medium">
|
|
430
|
+
{titles.get(otherId) ?? otherId}
|
|
431
|
+
</span>
|
|
432
|
+
<span className="text-xs text-muted-foreground">
|
|
433
|
+
{outgoing ? i18n.t("knowledge.linkOutgoing") : i18n.t("knowledge.linkIncoming")}
|
|
434
|
+
</span>
|
|
435
|
+
</span>
|
|
436
|
+
</button>
|
|
437
|
+
</li>
|
|
438
|
+
);
|
|
439
|
+
})}
|
|
440
|
+
{links.data?.items.length === 0 && (
|
|
441
|
+
<li className="text-sm text-muted-foreground">{i18n.t("knowledge.noLinks")}</li>
|
|
442
|
+
)}
|
|
443
|
+
</ul>
|
|
444
|
+
</Modal>
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// The one share dialog in the product. It sits on the folder that holds the documents and the
|
|
449
|
+
// flows, which is where a permission decision belongs (ADR-0004 §2). Which verbs it offers is the
|
|
450
|
+
// server's answer, not this component's: `execute` never appears on a document, because a document
|
|
451
|
+
// has nothing to run.
|
|
452
|
+
function ShareKnowledge({ node, close }: { node: KnowledgeNode; close(): void }) {
|
|
453
|
+
const { data, i18n } = useIntelRouterContext();
|
|
454
|
+
const queryClient = useQueryClient();
|
|
455
|
+
const [email, setEmail] = useState("");
|
|
456
|
+
const [verbs, setVerbs] = useState<ResourceVerb[]>(["read"]);
|
|
457
|
+
// What the grant just made does not cover. It survives the form being cleared, because it is the
|
|
458
|
+
// answer to the question the user just asked and they need a moment to read it.
|
|
459
|
+
const [unreadable, setUnreadable] = useState<UnreadableKnowledge | null>(null);
|
|
460
|
+
const grants = useQuery({
|
|
461
|
+
queryKey: ["knowledge-grants", node.id],
|
|
462
|
+
queryFn: () => data.listKnowledgeGrants(node.id),
|
|
463
|
+
});
|
|
464
|
+
const share = useMutation({
|
|
465
|
+
// One request per verb, because one grant is one verb. The keys differ so a retry of the whole
|
|
466
|
+
// form replays each verb on its own rather than collapsing them into one.
|
|
467
|
+
mutationFn: async () => {
|
|
468
|
+
let last: UnreadableKnowledge | null = null;
|
|
469
|
+
for (const verb of verbs) {
|
|
470
|
+
// The last verb's answer is the one kept: every request describes the access in force after
|
|
471
|
+
// it, so the newest is the only one still true.
|
|
472
|
+
last = (
|
|
473
|
+
await data.shareKnowledge({
|
|
474
|
+
resourceId: node.id,
|
|
475
|
+
principal: { type: "email", email },
|
|
476
|
+
verb,
|
|
477
|
+
expiresAt: null,
|
|
478
|
+
idempotencyKey: crypto.randomUUID(),
|
|
479
|
+
})
|
|
480
|
+
).unreadable;
|
|
481
|
+
}
|
|
482
|
+
return last;
|
|
483
|
+
},
|
|
484
|
+
onSuccess: (result) => {
|
|
485
|
+
setEmail("");
|
|
486
|
+
setUnreadable(result);
|
|
487
|
+
},
|
|
488
|
+
// ⚠️ `onSettled`, not `onSuccess`. One request per verb means a run can end halfway: three verbs
|
|
489
|
+
// granted, the fourth refused. On `onSuccess` the list would then still show the state from
|
|
490
|
+
// before, and the user would read a permission picture that is not the one in force. Of all the
|
|
491
|
+
// things to be silently wrong about, access is the worst.
|
|
492
|
+
onSettled: async () => {
|
|
493
|
+
await queryClient.invalidateQueries({ queryKey: ["knowledge-grants", node.id] });
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
const revoke = useMutation({
|
|
497
|
+
mutationFn: (grantId: string) =>
|
|
498
|
+
data.revokeKnowledgeGrant({
|
|
499
|
+
resourceId: node.id,
|
|
500
|
+
grantId,
|
|
501
|
+
idempotencyKey: crypto.randomUUID(),
|
|
502
|
+
}),
|
|
503
|
+
onSettled: async () => {
|
|
504
|
+
await queryClient.invalidateQueries({ queryKey: ["knowledge-grants", node.id] });
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
return (
|
|
509
|
+
<Modal title={i18n.t("knowledge.share")} close={close}>
|
|
510
|
+
{(share.isError || revoke.isError || grants.isError) && (
|
|
511
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
512
|
+
{i18n.t("knowledge.shareFailed")}
|
|
513
|
+
</p>
|
|
514
|
+
)}
|
|
515
|
+
{/* ⚠️ `status`, not `alert`, and beside the grant rather than in place of it: the access was
|
|
516
|
+
given. A knowledge reference across the folder edge is a possible failure, not a way around
|
|
517
|
+
permissions, so nothing here blocks anything (ADR-0004 §4). */}
|
|
518
|
+
{unreadable && (unreadable.titles.length > 0 || unreadable.hidden > 0) && (
|
|
519
|
+
<div role="status" className="mb-4 rounded-md border bg-muted p-3 text-sm">
|
|
520
|
+
{unreadable.titles.length > 0 ? (
|
|
521
|
+
<p>
|
|
522
|
+
{i18n.t("knowledge.shareUnreadable", { titles: unreadable.titles.join(", ") })}
|
|
523
|
+
{unreadable.hidden > 0
|
|
524
|
+
? ` ${i18n.t("knowledge.shareUnreadableMore", { count: unreadable.hidden })}`
|
|
525
|
+
: ""}
|
|
526
|
+
</p>
|
|
527
|
+
) : (
|
|
528
|
+
<p>{i18n.t("knowledge.shareUnreadableHidden", { count: unreadable.hidden })}</p>
|
|
529
|
+
)}
|
|
530
|
+
<p className="mt-1 text-xs text-muted-foreground">
|
|
531
|
+
{i18n.t("knowledge.shareUnreadableHint")}
|
|
532
|
+
</p>
|
|
533
|
+
</div>
|
|
534
|
+
)}
|
|
535
|
+
<ul className="mb-5 max-h-44 space-y-2 overflow-y-auto">
|
|
536
|
+
{grants.data?.items.map((grant) => (
|
|
537
|
+
<li
|
|
538
|
+
key={grant.id}
|
|
539
|
+
className="flex items-center justify-between gap-3 rounded-md border p-3 text-sm"
|
|
540
|
+
>
|
|
541
|
+
<span className="min-w-0 truncate">
|
|
542
|
+
{grant.principal.type === "email"
|
|
543
|
+
? grant.principal.email
|
|
544
|
+
: grant.principal.type === "user"
|
|
545
|
+
? grant.principal.id
|
|
546
|
+
: i18n.t("knowledge.organization")}
|
|
547
|
+
<span className="ml-2 text-xs text-muted-foreground">
|
|
548
|
+
{i18n.t(`knowledge.verb.${grant.verb}`)}
|
|
549
|
+
</span>
|
|
550
|
+
</span>
|
|
551
|
+
<button
|
|
552
|
+
type="button"
|
|
553
|
+
onClick={() => revoke.mutate(grant.id)}
|
|
554
|
+
aria-label={i18n.t("knowledge.revokeShare")}
|
|
555
|
+
className="rounded-md p-2 text-destructive outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
556
|
+
>
|
|
557
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
558
|
+
</button>
|
|
559
|
+
</li>
|
|
560
|
+
))}
|
|
561
|
+
{grants.data?.items.length === 0 && (
|
|
562
|
+
<li className="text-sm text-muted-foreground">{i18n.t("knowledge.noGrants")}</li>
|
|
563
|
+
)}
|
|
564
|
+
</ul>
|
|
565
|
+
<form
|
|
566
|
+
onSubmit={(event) => {
|
|
567
|
+
event.preventDefault();
|
|
568
|
+
share.mutate();
|
|
569
|
+
}}
|
|
570
|
+
className="space-y-4 border-t pt-5"
|
|
571
|
+
>
|
|
572
|
+
<label className="block text-sm font-medium">
|
|
573
|
+
{i18n.t("knowledge.email")}
|
|
574
|
+
<input
|
|
575
|
+
type="email"
|
|
576
|
+
required
|
|
577
|
+
value={email}
|
|
578
|
+
onChange={(event) => setEmail(event.target.value)}
|
|
579
|
+
className="mt-2 w-full rounded-md border bg-background px-3 py-2 outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
580
|
+
/>
|
|
581
|
+
</label>
|
|
582
|
+
<fieldset className="space-y-2">
|
|
583
|
+
<legend className="text-sm font-medium">{i18n.t("knowledge.verbs")}</legend>
|
|
584
|
+
{(grants.data?.applicableVerbs ?? []).map((verb) => (
|
|
585
|
+
<label key={verb} className="flex items-center gap-2 text-sm">
|
|
586
|
+
<input
|
|
587
|
+
type="checkbox"
|
|
588
|
+
checked={verbs.includes(verb)}
|
|
589
|
+
onChange={(event) =>
|
|
590
|
+
setVerbs((current) =>
|
|
591
|
+
event.target.checked
|
|
592
|
+
? [...current, verb]
|
|
593
|
+
: current.filter((candidate) => candidate !== verb),
|
|
594
|
+
)
|
|
595
|
+
}
|
|
596
|
+
className="size-4 rounded border outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
597
|
+
/>
|
|
598
|
+
<span>{i18n.t(`knowledge.verb.${verb}`)}</span>
|
|
599
|
+
<span className="text-xs text-muted-foreground">
|
|
600
|
+
{i18n.t(`knowledge.verbHint.${verb}`)}
|
|
601
|
+
</span>
|
|
602
|
+
</label>
|
|
603
|
+
))}
|
|
604
|
+
</fieldset>
|
|
605
|
+
<button
|
|
606
|
+
type="submit"
|
|
607
|
+
disabled={share.isPending || verbs.length === 0}
|
|
608
|
+
className="w-full rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground outline-none hover:bg-primary/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50"
|
|
609
|
+
>
|
|
610
|
+
{i18n.t("knowledge.shareAction")}
|
|
611
|
+
</button>
|
|
612
|
+
</form>
|
|
613
|
+
</Modal>
|
|
614
|
+
);
|
|
615
|
+
}
|
|
@@ -1,14 +1,29 @@
|
|
|
1
|
-
|
|
1
|
+
// The ways one selected level can be shown. `editor` is the absence of the parameter rather than a
|
|
2
|
+
// value of its own, so a plain link is always the editor and every link written before #35 keeps
|
|
3
|
+
// meaning what it meant.
|
|
4
|
+
export const IntelViews = ["editor", "graph", "runs"] as const;
|
|
5
|
+
export type IntelView = (typeof IntelViews)[number];
|
|
6
|
+
|
|
7
|
+
export type SelectionSearch = Record<string, unknown> & {
|
|
8
|
+
select?: string;
|
|
9
|
+
view?: Exclude<IntelView, "editor">;
|
|
10
|
+
};
|
|
2
11
|
|
|
3
12
|
// One search parameter carries "open this one" into an area: the header search names a hit, the
|
|
4
13
|
// screen for that area selects it. Keeping it in the URL is what makes a hit reachable with Enter
|
|
5
14
|
// alone — the dialog never reaches into a screen's own state.
|
|
6
15
|
//
|
|
16
|
+
// `view` is the second: which way the selected level is shown — the editor, the relation graph
|
|
17
|
+
// (#19), or the runs of the selected flow (#35). It sits in the URL for the same reason `select`
|
|
18
|
+
// does: what someone is looking at should survive a reload and be linkable.
|
|
19
|
+
//
|
|
7
20
|
// ⚠️ Unknown parameters are passed through rather than dropped. The portal's connect flow returns
|
|
8
21
|
// to `/tools` with its own marker, and a validator that kept only what it knows would delete it.
|
|
9
22
|
export function selectionSearch(search: Record<string, unknown>): SelectionSearch {
|
|
10
|
-
const { select, ...rest } = search;
|
|
11
|
-
|
|
23
|
+
const { select, view, ...rest } = search;
|
|
24
|
+
const withSelect =
|
|
25
|
+
typeof select === "string" && select.length > 0 ? { ...rest, select } : { ...rest };
|
|
26
|
+
return view === "graph" || view === "runs" ? { ...withSelect, view } : withSelect;
|
|
12
27
|
}
|
|
13
28
|
|
|
14
29
|
// Screens are lazy route components with no route object at hand, so they read the parameter off
|
|
@@ -17,3 +32,12 @@ export function selectedFrom(search: unknown): string | null {
|
|
|
17
32
|
const select = (search as { select?: unknown } | null)?.select;
|
|
18
33
|
return typeof select === "string" && select.length > 0 ? select : null;
|
|
19
34
|
}
|
|
35
|
+
|
|
36
|
+
export function viewFrom(search: unknown): IntelView {
|
|
37
|
+
const view = (search as { view?: unknown } | null)?.view;
|
|
38
|
+
return view === "graph" || view === "runs" ? view : "editor";
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function graphViewFrom(search: unknown): boolean {
|
|
42
|
+
return viewFrom(search) === "graph";
|
|
43
|
+
}
|