@anchrd/intel-ui 0.5.0 → 0.7.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.
@@ -1,26 +1,34 @@
1
1
  import type {
2
- ContextPolicy,
3
2
  Flow,
3
+ FlowValidation,
4
4
  KnowledgeNode,
5
5
  ResourceVerb,
6
6
  UnreadableKnowledge,
7
7
  } from "@anchrd/intel-contract";
8
8
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
9
9
  import { useNavigate, useRouterState } from "@tanstack/react-router";
10
- import { Archive, CornerLeftUp, Ellipsis, Link2, Pencil, Share2, Trash2 } from "lucide-react";
10
+ import {
11
+ Archive,
12
+ CornerLeftUp,
13
+ Ellipsis,
14
+ Link2,
15
+ Pencil,
16
+ Share2,
17
+ ShieldCheck,
18
+ Trash2,
19
+ } from "lucide-react";
20
+ import type * as React from "react";
11
21
  import { useState } from "react";
22
+ import { moveErrorKey, useTreeMove } from "@/app/tree-move/tree-move.tsx";
12
23
  import {
13
24
  DropdownMenu,
14
25
  DropdownMenuContent,
15
26
  DropdownMenuItem,
16
- DropdownMenuRadioGroup,
17
- DropdownMenuRadioItem,
18
27
  DropdownMenuSeparator,
19
- DropdownMenuSub,
20
- DropdownMenuSubContent,
21
- DropdownMenuSubTrigger,
22
28
  DropdownMenuTrigger,
23
29
  } from "@/components/ui/dropdown-menu";
30
+ import { flowEntry } from "@/data/intel-data-provider/intel-data-provider.ts";
31
+ import type { TreeEntry } from "@/data/intel-data-provider/intel-data-provider.types.ts";
24
32
  import { Modal } from "@/modal/modal.tsx";
25
33
  import { useIntelRouterContext } from "@/router/router-context.ts";
26
34
  import { selectedFrom } from "@/router/selection-search.ts";
@@ -28,9 +36,11 @@ import { selectedFrom } from "@/router/selection-search.ts";
28
36
  /**
29
37
  * What a resource's own actions are, at the place the resource stands (#24).
30
38
  *
31
- * One component for both places it appears the tree row in the sidebar and the title line of the
32
- * open document or flow because they are the same menu on the same thing. Two components would be
33
- * the loose icon row this ticket removes, only spread over two files instead of one.
39
+ * ⚠️ Since #58 there is exactly one such place: the title line of the open document, table,
40
+ * attachment, folder or flow. The tree row used to carry the same menu, which put ten buttons into a
41
+ * 240px column and made the sidebar something to read buttons in rather than titles. Everything the
42
+ * row menu could do is reached here — including moving, which is why this component owns the folder
43
+ * picker rather than being handed one.
34
44
  *
35
45
  * ⚠️ An entry that does not apply is absent, never disabled: a folder has no retrieval mode, a flow
36
46
  * is shared through the folder it is filed in rather than on its own (ADR-0004 §2). A greyed-out row
@@ -40,6 +50,21 @@ export type ResourceTarget =
40
50
  | { type: "knowledge"; node: KnowledgeNode }
41
51
  | { type: "flow"; flow: Flow };
42
52
 
53
+ // The same thing as a tree row, because that is what a move works on: a row with a place in the
54
+ // shared tree. The two shapes are the same record seen from two screens, so this is a re-labelling
55
+ // and never a second source of truth.
56
+ function entryOf(target: ResourceTarget): TreeEntry {
57
+ return target.type === "flow"
58
+ ? flowEntry(target.flow)
59
+ : {
60
+ type: "knowledge",
61
+ id: target.node.id,
62
+ title: target.node.title,
63
+ kind: target.node.kind,
64
+ node: target.node,
65
+ };
66
+ }
67
+
43
68
  function idOf(target: ResourceTarget): string {
44
69
  return target.type === "knowledge" ? target.node.id : target.flow.id;
45
70
  }
@@ -72,24 +97,21 @@ export function resourceErrorKey(error: unknown): string {
72
97
  }
73
98
  }
74
99
 
75
- const contextPolicies: readonly ContextPolicy[] = ["pinned", "relevant", "explicit"];
76
-
77
100
  /**
78
101
  * The three-dot menu.
79
102
  *
80
- * `variant` is only how the trigger is painted. In the sidebar it follows the plus exactly hidden
81
- * until the row is hovered or something in it takes focus, always there below `md` because a
82
- * capability a keyboard cannot reach is missing for everyone who does not use a mouse. In a title
83
- * line it is a plain icon button beside the other header actions.
103
+ * `variant` is only how the trigger is painted. `title` is the one in use: a plain icon button at
104
+ * the end of a title line, put there by `TitleRow` and by nobody else, so it is always the last
105
+ * thing in that line (#53). `row` is the sidebar form hidden until the row is hovered or something
106
+ * in it takes focus — and is unused since #58; it is kept because the trigger's two paintings are
107
+ * the only difference between the two places, and re-deriving it would be the harder half.
84
108
  */
85
109
  export function ResourceMenu({
86
110
  target,
87
111
  variant,
88
- onMove,
89
112
  }: {
90
113
  target: ResourceTarget;
91
114
  variant: "row" | "title";
92
- onMove?: (() => void) | undefined;
93
115
  }) {
94
116
  const { data, i18n } = useIntelRouterContext();
95
117
  const queryClient = useQueryClient();
@@ -98,6 +120,18 @@ export function ResourceMenu({
98
120
  const [sharing, setSharing] = useState(false);
99
121
  const [linksOpen, setLinksOpen] = useState(false);
100
122
  const selected = useRouterState({ select: (state) => selectedFrom(state.location.search) });
123
+ // ⚠️ Dragging is a pointer gesture and nothing else: no keyboard, no screen reader, no touch worth
124
+ // the name. #27 gave moving a second, equal route through a folder picker, and that route lived in
125
+ // the tree row's menu. With the row menu gone (#58) it lives here, or it does not exist.
126
+ const move = useTreeMove();
127
+ // ⚠️ Not a query. A validation is a snapshot taken when somebody asks (#72, ADR-0003): cached
128
+ // under a key it would be re-served later as if it still held, and the one thing it must never
129
+ // claim is that a moment which has passed is still true.
130
+ const [validation, setValidation] = useState<FlowValidation | null>(null);
131
+ const validate = useMutation({
132
+ mutationFn: () => data.validateFlow(id),
133
+ onSuccess: setValidation,
134
+ });
101
135
 
102
136
  const id = idOf(target);
103
137
  const title = titleOf(target);
@@ -168,31 +202,17 @@ export function ResourceMenu({
168
202
  },
169
203
  });
170
204
 
171
- const setContextPolicy = useMutation({
172
- mutationFn: async (contextPolicy: ContextPolicy) => {
173
- if (target.type === "flow") throw new Error("A flow has no retrieval mode");
174
- await data.updateKnowledge({
175
- nodeId: target.node.id,
176
- baseUpdatedAt: target.node.updatedAt,
177
- contextPolicy,
178
- idempotencyKey: crypto.randomUUID(),
179
- });
180
- },
181
- onSuccess: refresh,
182
- });
183
-
184
205
  const failure = rename.isError
185
206
  ? null // the rename dialog words its own refusal, beside the field that caused it
186
207
  : archive.isError
187
208
  ? archive.error
188
- : setContextPolicy.isError
189
- ? setContextPolicy.error
190
- : undefined;
209
+ : undefined;
191
210
 
192
211
  return (
193
212
  <>
194
213
  <DropdownMenu>
195
214
  <DropdownMenuTrigger
215
+ data-resource-menu=""
196
216
  aria-label={i18n.t("resource.menu", { title })}
197
217
  className={
198
218
  variant === "row"
@@ -207,33 +227,19 @@ export function ResourceMenu({
207
227
  <Pencil aria-hidden="true" />
208
228
  {i18n.t("resource.rename")}
209
229
  </DropdownMenuItem>
210
- {onMove ? (
211
- <DropdownMenuItem onSelect={onMove}>
212
- <CornerLeftUp aria-hidden="true" />
213
- {i18n.t("tree.move.action")}
230
+ <DropdownMenuItem onSelect={() => move.start(entryOf(target), null)}>
231
+ <CornerLeftUp aria-hidden="true" />
232
+ {i18n.t("tree.move.action")}
233
+ </DropdownMenuItem>
234
+ {/* Only a flow can be run, so only a flow can be asked whether it would. Rarely needed —
235
+ which is why it is in the menu and not in the title line (#72). */}
236
+ {node === null ? (
237
+ <DropdownMenuItem onSelect={() => validate.mutate()}>
238
+ <ShieldCheck aria-hidden="true" />
239
+ {i18n.t("flows.validate")}
214
240
  </DropdownMenuItem>
215
241
  ) : null}
216
242
  {retrievable || linkable || node ? <DropdownMenuSeparator /> : null}
217
- {/* ⚠️ A submenu with a checked value, not an embedded `select`. A form control inside a
218
- menu takes the keyboard away from the menu that contains it, and the current value is
219
- then only readable by opening a second widget. */}
220
- {retrievable && node ? (
221
- <DropdownMenuSub>
222
- <DropdownMenuSubTrigger>{i18n.t("knowledge.contextPolicy")}</DropdownMenuSubTrigger>
223
- <DropdownMenuSubContent>
224
- <DropdownMenuRadioGroup
225
- value={node.contextPolicy}
226
- onValueChange={(value) => setContextPolicy.mutate(value as ContextPolicy)}
227
- >
228
- {contextPolicies.map((policy) => (
229
- <DropdownMenuRadioItem key={policy} value={policy}>
230
- {i18n.t(`knowledge.context.${policy}`)}
231
- </DropdownMenuRadioItem>
232
- ))}
233
- </DropdownMenuRadioGroup>
234
- </DropdownMenuSubContent>
235
- </DropdownMenuSub>
236
- ) : null}
237
243
  {linkable ? (
238
244
  <DropdownMenuItem onSelect={() => setLinksOpen(true)}>
239
245
  <Link2 aria-hidden="true" />
@@ -264,15 +270,42 @@ export function ResourceMenu({
264
270
  </DropdownMenuContent>
265
271
  </DropdownMenu>
266
272
  {/* The menu closes on selection, so a refusal has nowhere to live inside it. It stands over
267
- the screen instead, where it is read whether the tree or the title line asked. */}
273
+ the screen instead, where it is read whatever the title line asked for. */}
268
274
  {failure !== undefined && failure !== null ? (
269
- <p
270
- role="alert"
271
- 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"
272
- >
273
- {i18n.t(resourceErrorKey(failure))}
274
- </p>
275
+ <MenuFailure>{i18n.t(resourceErrorKey(failure))}</MenuFailure>
275
276
  ) : null}
277
+ {/* ⚠️ A move has four refusals of its own and they are told apart by `moveErrorKey`, not by
278
+ `resourceErrorKey`: "that is not a folder" and "that would be a loop" have no equivalent
279
+ among the changes above, and one shared sentence would leave the reader guessing. */}
280
+ {move.error ? <MenuFailure>{i18n.t(moveErrorKey(move.error))}</MenuFailure> : null}
281
+ {/* ⚠️ Every reason at once, not the first one. Somebody with two missing tools should not have
282
+ to ask twice — and a success says so out loud, because an empty menu after a click reads
283
+ as "nothing happened" rather than "nothing is in the way". */}
284
+ {validation ? (
285
+ <Modal title={i18n.t("flows.validate")} close={() => setValidation(null)}>
286
+ {validation.problems.length === 0 ? (
287
+ <p className="text-sm">{i18n.t("flows.validateReady")}</p>
288
+ ) : (
289
+ <ul className="space-y-2">
290
+ {validation.problems.map((problem) => (
291
+ <li key={problem.code} className="rounded-md border p-3 text-sm">
292
+ <span className="block font-medium">
293
+ {i18n.t(`flows.problem.${problem.code}`)}
294
+ </span>
295
+ <span className="mt-1 block text-xs text-muted-foreground">{problem.detail}</span>
296
+ </li>
297
+ ))}
298
+ </ul>
299
+ )}
300
+ {/* A snapshot says when it was taken, or it will be read as a standing verdict. */}
301
+ <p className="mt-4 text-xs text-muted-foreground">
302
+ {i18n.t("flows.validateWhen", {
303
+ when: new Date(validation.checkedAt).toLocaleString(),
304
+ })}
305
+ </p>
306
+ </Modal>
307
+ ) : null}
308
+ {move.dialog}
276
309
  {renaming ? (
277
310
  <RenameDialog
278
311
  title={title}
@@ -291,6 +324,18 @@ export function ResourceMenu({
291
324
  );
292
325
  }
293
326
 
327
+ // One refusal, over the screen rather than in the menu that is already gone by the time it arrives.
328
+ function MenuFailure({ children }: { children: React.ReactNode }) {
329
+ return (
330
+ <p
331
+ role="alert"
332
+ 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"
333
+ >
334
+ {children}
335
+ </p>
336
+ );
337
+ }
338
+
294
339
  function RenameDialog({
295
340
  title,
296
341
  pending,
@@ -0,0 +1,49 @@
1
+ import type * as React from "react";
2
+ import { ResourceMenu, type ResourceTarget } from "@/resource-menu/resource-menu.tsx";
3
+
4
+ /**
5
+ * The one line an open thing gets: its name on the left, everything that acts on it on the right
6
+ * (#53).
7
+ *
8
+ * ⚠️ The order of the right-hand group belongs to this component, not to its callers. Whatever they
9
+ * pass as `children` is rendered before the menu and the menu is appended last, always — so a screen
10
+ * that grows a new button cannot push the three dots to the left, and there is no second place where
11
+ * the rule could be forgotten. That is the whole point: the menu holds rename, move, share and
12
+ * archive, and a position one has to look for is a position one stops using.
13
+ *
14
+ * The two slots are for what belongs to this thing but lives further down the tree, where its state
15
+ * is. `title-meta` is a quiet word beside the name (a table's row count); `title-actions` is a
16
+ * button in the group (saving a document, exporting a table). Both are filled through `ActionSlot`
17
+ * and both sit before the menu for the same reason `children` do.
18
+ */
19
+ export function TitleRow({
20
+ title,
21
+ description,
22
+ target,
23
+ children,
24
+ }: {
25
+ title: string;
26
+ description?: string | null;
27
+ target: ResourceTarget;
28
+ children?: React.ReactNode;
29
+ }) {
30
+ return (
31
+ <div className="flex items-start justify-between gap-5 border-b px-6 py-4">
32
+ <div className="min-w-0">
33
+ <div className="flex min-w-0 items-baseline gap-3">
34
+ <h2 className="truncate text-lg font-semibold">{title}</h2>
35
+ <span data-slot="title-meta" className="shrink-0 text-sm text-muted-foreground" />
36
+ </div>
37
+ {description ? <p className="mt-1 text-sm text-muted-foreground">{description}</p> : null}
38
+ </div>
39
+ {/* ⚠️ Document order is tab order here — nothing carries a `tabIndex`. The menu is therefore
40
+ reached last by the keyboard for the same reason it stands last on screen, and the two
41
+ cannot drift apart without someone rewriting this line. */}
42
+ <div className="flex shrink-0 items-center gap-2">
43
+ <div data-slot="title-actions" className="flex items-center gap-2" />
44
+ {children}
45
+ <ResourceMenu target={target} variant="title" />
46
+ </div>
47
+ </div>
48
+ );
49
+ }
@@ -1,10 +1,9 @@
1
1
  import type { ToolCapability } from "@anchrd/intel-contract";
2
2
  import { useQuery } from "@tanstack/react-query";
3
3
  import { useRouterState } from "@tanstack/react-router";
4
- import { AlertTriangle, ChevronRight, LogIn, PlugZap, Wrench } from "lucide-react";
5
- import type * as React from "react";
6
- import { ActionSlot } from "@/app/action-slot/action-slot.tsx";
7
- import { Button, buttonVariants } from "@/components/ui/button";
4
+ import { AlertTriangle, ChevronRight, PlugZap, ShieldOff, Wrench } from "lucide-react";
5
+ import * as React from "react";
6
+ import { Button } from "@/components/ui/button";
8
7
  import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
9
8
  import type { I18n } from "@/i18n/i18n.types.ts";
10
9
  import { useIntelRouterContext } from "@/router/router-context.ts";
@@ -20,6 +19,23 @@ function toolOrigin(name: string): string | null {
20
19
  return boundary > 0 ? name.slice(0, boundary) : null;
21
20
  }
22
21
 
22
+ // Signed in to Intel is signed in to the portal, so the sign-in is attempted at most once per
23
+ // visit. A second attempt after a refusal would be a redirect loop with an unchanging answer, and
24
+ // the marker in the URL only survives until the next navigation — the browser session remembers it
25
+ // instead (#60).
26
+ const SignInAttemptKey = "intel.portal-sign-in-attempted";
27
+
28
+ // Reading `sessionStorage` throws outright in a few privacy modes, so the guard is a try, not a
29
+ // feature check. Losing the note costs one extra redirect, and the marker the refusal leaves in the
30
+ // URL still ends the walk — it must never cost the screen.
31
+ function attemptStore(): Storage | null {
32
+ try {
33
+ return typeof window === "undefined" ? null : window.sessionStorage;
34
+ } catch {
35
+ return null;
36
+ }
37
+ }
38
+
23
39
  // The catalog is one live `tools/list` with the signed-in user's own portal token. Nothing here is
24
40
  // stored, mirrored or administered — the screen shows what the portal answers and nothing else.
25
41
  export function Tools() {
@@ -28,36 +44,34 @@ export function Tools() {
28
44
  // A hit from the header search arrives as `?select=<tool name>`, and the row it names opens with
29
45
  // the list. Nothing else about the row changes: it is still only a disclosure.
30
46
  const requested = useRouterState({ select: (state) => selectedFrom(state.location.search) });
31
- // The portal connect flow returns here with a marker when it refuses. Only its presence is used:
32
- // the value is attacker-controlled and could carry provider detail, so it is never rendered.
33
- const connectFailed =
47
+ // The silent sign-in returns here with a marker when the portal refused. Only its presence is
48
+ // used: the value comes from outside and could carry provider detail, so it is never rendered.
49
+ const refused =
34
50
  typeof window !== "undefined" &&
35
51
  new URLSearchParams(window.location.search).has("connectError");
52
+ const [signingIn, setSigningIn] = React.useState(false);
53
+
54
+ // ⚠️ Nobody is asked to start this. The portal sign-in is a browser redirect, so the screen
55
+ // begins it itself the moment the catalog says there is no portal session yet — that is the whole
56
+ // of what replaced the "Connect the portal" button.
57
+ React.useEffect(() => {
58
+ if (!catalog.data) return;
59
+ const attempts = attemptStore();
60
+ if (catalog.data.portalConnected) {
61
+ // An answered sign-in frees the next one: when the token later expires beyond renewal, the
62
+ // same silent walk runs again rather than stopping at a screen.
63
+ attempts?.removeItem(SignInAttemptKey);
64
+ return;
65
+ }
66
+ if (refused || attempts?.getItem(SignInAttemptKey)) return;
67
+ attempts?.setItem(SignInAttemptKey, "1");
68
+ setSigningIn(true);
69
+ data.startPortalSignIn("/tools");
70
+ }, [catalog.data, data, refused]);
36
71
 
37
72
  return (
38
73
  <div className="min-h-full p-8">
39
- {catalog.data?.portalConnected ? (
40
- <ActionSlot>
41
- <a
42
- href={data.portalConnectUrl("/tools")}
43
- className={buttonVariants({ variant: "outline", size: "sm" })}
44
- >
45
- <LogIn aria-hidden="true" />
46
- {i18n.t("tools.reconnect")}
47
- </a>
48
- </ActionSlot>
49
- ) : null}
50
-
51
74
  <div className="mx-auto w-full max-w-4xl space-y-5">
52
- {connectFailed && (
53
- <p
54
- role="alert"
55
- className="rounded-md border border-destructive/30 p-3 text-sm text-destructive"
56
- >
57
- {i18n.t("tools.connectFailed")}
58
- </p>
59
- )}
60
-
61
75
  {catalog.isPending ? (
62
76
  <p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
63
77
  ) : catalog.isError ? (
@@ -74,16 +88,21 @@ export function Tools() {
74
88
  </Button>
75
89
  </Notice>
76
90
  ) : !catalog.data.portalConnected ? (
77
- <Notice
78
- icon={<PlugZap aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}
79
- title={i18n.t("tools.disconnected")}
80
- help={i18n.t("tools.disconnectedHelp")}
81
- >
82
- <a href={data.portalConnectUrl("/tools")} className={buttonVariants()}>
83
- <LogIn aria-hidden="true" />
84
- {i18n.t("tools.connect")}
85
- </a>
86
- </Notice>
91
+ signingIn ? (
92
+ <p className="text-sm text-muted-foreground">{i18n.t("tools.signingIn")}</p>
93
+ ) : (
94
+ // The end of the silent walk for somebody no Access policy carries. It is a sentence
95
+ // about access, not an invitation to connect: there is nothing they could click that
96
+ // would change the answer, and what the portal replied is not repeated here.
97
+ <Notice
98
+ alert
99
+ icon={
100
+ <ShieldOff aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />
101
+ }
102
+ title={i18n.t("tools.noAccess")}
103
+ help={i18n.t("tools.noAccessHelp")}
104
+ />
105
+ )
87
106
  ) : catalog.data.items.length === 0 ? (
88
107
  <Notice
89
108
  icon={<Wrench aria-hidden="true" className="mx-auto size-8 text-muted-foreground" />}