@anchrd/intel-ui 0.21.0 → 0.23.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 +47 -8
- package/src/app/app.tsx +7 -38
- package/src/app/settings-dialog/settings-dialog.tsx +38 -5
- package/src/app/user-footer/user-footer.tsx +31 -13
- package/src/app-root/app-root.tsx +109 -0
- package/src/app-root/app-root.types.ts +27 -0
- package/src/archive/archive.tsx +76 -2
- package/src/data/intel-data-provider/intel-data-provider.ts +16 -0
- package/src/data/intel-data-provider/intel-data-provider.types.ts +8 -0
- package/src/flow-runs/flow-runs.tsx +3 -1
- package/src/flows/flows.tsx +5 -11
- package/src/folder-contents/folder-contents.tsx +3 -1
- package/src/i18n/de.json +15 -4
- package/src/i18n/en.json +15 -4
- package/src/i18n/es.json +15 -4
- package/src/main.tsx +26 -32
- package/src/node-table/node-table.tsx +10 -50
- package/src/nodes/nodes.tsx +21 -18
- package/src/resource-menu/resource-menu.tsx +62 -1
- package/src/time/time-context.tsx +72 -0
- package/src/time/time.ts +140 -0
- package/src/components/ui/breadcrumb.tsx +0 -102
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@anchrd/intel-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.23.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.16.0",
|
|
37
37
|
"@blocknote/core": "^0.52.1",
|
|
38
38
|
"@blocknote/react": "^0.52.1",
|
|
39
39
|
"@blocknote/shadcn": "^0.52.1",
|
|
@@ -487,7 +487,16 @@ export function AppTree() {
|
|
|
487
487
|
onClick={() => toggle(level, !isOpen)}
|
|
488
488
|
// The icons inside a coloured row need a step of their own, or their hover would be
|
|
489
489
|
// the row's colour on the row's colour and read as nothing at all.
|
|
490
|
-
|
|
490
|
+
//
|
|
491
|
+
// ⚠️ A square of exactly `TREE_GUTTER` with the icon CENTRED in it (#453). It used to
|
|
492
|
+
// be `p-1` around a 14px icon — 22px wide, so the arrow's centre sat at 11px while the
|
|
493
|
+
// line of the level below started at 14px: the line ran PAST the arrow instead of
|
|
494
|
+
// under it. On the fourth level that line is the only thing left saying whose content
|
|
495
|
+
// one is reading, and missing its arrow it reads as a separation instead of a
|
|
496
|
+
// belonging. Centred, the hit area also gains the air on the left it never had, and it
|
|
497
|
+
// gets bigger rather than smaller — an arrow that sits well but is harder to hit is no
|
|
498
|
+
// improvement, on touch least of all.
|
|
499
|
+
className={`${TREE_GUTTER} flex items-center justify-center rounded-md text-sidebar-foreground/70 outline-none hover:bg-sidebar-accent-foreground/10 focus-visible:ring-2 focus-visible:ring-sidebar-ring`}
|
|
491
500
|
>
|
|
492
501
|
<ChevronRight
|
|
493
502
|
aria-hidden="true"
|
|
@@ -495,7 +504,9 @@ export function AppTree() {
|
|
|
495
504
|
/>
|
|
496
505
|
</button>
|
|
497
506
|
) : (
|
|
498
|
-
|
|
507
|
+
// ⚠️ The SAME width as the button, not one step less: a row without an arrow used to sit
|
|
508
|
+
// 2px left of one with an arrow, and nothing said why.
|
|
509
|
+
<span aria-hidden="true" className={TREE_GUTTER} />
|
|
499
510
|
)}
|
|
500
511
|
{/* The row's own control is what is grabbed and what is dropped on: a real button, so the
|
|
501
512
|
gesture rides on something that is already focusable and named rather than on a bare
|
|
@@ -555,7 +566,7 @@ export function AppTree() {
|
|
|
555
566
|
under — indentation says whose contents these are to anyone who can see it, and this is
|
|
556
567
|
the same sentence for anyone who cannot. */}
|
|
557
568
|
{isOpen ? (
|
|
558
|
-
<div className=
|
|
569
|
+
<div className={TREE_LINE}>
|
|
559
570
|
{renderLevel(
|
|
560
571
|
level,
|
|
561
572
|
new Set([...ancestors, entry.id]),
|
|
@@ -616,11 +627,14 @@ export function AppTree() {
|
|
|
616
627
|
which is exactly when somebody needs to be told where to start. */}
|
|
617
628
|
{root?.isPending ? null : (
|
|
618
629
|
<div className="mt-1 flex items-center gap-0.5">
|
|
619
|
-
{/*
|
|
620
|
-
above it
|
|
621
|
-
|
|
630
|
+
{/* ⚠️ No gutter here any more (#453). It was meant to line the plus up with the row ICONS
|
|
631
|
+
above it — and the price was that the row stood half a step right of everything
|
|
632
|
+
belonging to the top level, so it read as a child of the list while it means the list
|
|
633
|
+
itself. It begins on the same left edge as the arrows of the top level, which is the
|
|
634
|
+
edge that says "this level". */}
|
|
622
635
|
<AddMenu
|
|
623
636
|
label={i18n.t("tree.addRoot")}
|
|
637
|
+
description={i18n.t("tree.addRoot.long")}
|
|
624
638
|
variant="row"
|
|
625
639
|
onSelect={(kind) => {
|
|
626
640
|
if (kind === "upload") startUpload(null);
|
|
@@ -657,6 +671,20 @@ export function AppTree() {
|
|
|
657
671
|
);
|
|
658
672
|
}
|
|
659
673
|
|
|
674
|
+
// ── The geometry of the tree: two measurements that belong together (#453) ───────────────────────
|
|
675
|
+
//
|
|
676
|
+
// The disclosure arrow sits in a 24px SQUARE, and the vertical line of an open level starts at 12px
|
|
677
|
+
// — the centre column of that square. The two used to be arrived at independently (`p-1` around a
|
|
678
|
+
// 14px icon = 22px, line at `ml-3.5` = 14px), which is why the line ran BESIDE its arrow rather than
|
|
679
|
+
// underneath it.
|
|
680
|
+
//
|
|
681
|
+
// ⚠️ The result is no small matter: on the fourth level the line is the only thing left saying whose
|
|
682
|
+
// content you are reading. If it misses its arrow, it reads as a separation instead of a belonging.
|
|
683
|
+
//
|
|
684
|
+
// That is why the two stand HERE side by side: whoever changes one sees the other.
|
|
685
|
+
const TREE_GUTTER = "size-6 shrink-0";
|
|
686
|
+
const TREE_LINE = "ml-3 border-l border-sidebar-border pl-2";
|
|
687
|
+
|
|
660
688
|
// ⚠️ Hover alone would hide this from a keyboard and from touch entirely, which is the same as not
|
|
661
689
|
// having it. It stays in the tab order, focus makes it visible, and below `md` it is always shown.
|
|
662
690
|
//
|
|
@@ -666,8 +694,19 @@ export function AppTree() {
|
|
|
666
694
|
// ⚠️ The two variants do not offer the same things, and the type says so rather than a comment: only
|
|
667
695
|
// the root can emit an import (#346), because only the root has no three-dot menu to hold one. A
|
|
668
696
|
// single wide signature would leave the folder row with a branch nothing can reach.
|
|
697
|
+
//
|
|
698
|
+
// ⚠️ In the `row` form the visible text and the accessible name are NOT the same string (#453). The
|
|
699
|
+
// sentence "Add at the top level" was cut to "Add at the top le…" in a 240px sidebar —
|
|
700
|
+
// a truncated sentence says less than a short word. The word is what one reads; the sentence stays
|
|
701
|
+
// as the name, because this row is the only way to start a tree and the only thing on screen when
|
|
702
|
+
// the tree is empty. Shorter must not mean that nobody is told where to begin.
|
|
669
703
|
type AddMenuProps =
|
|
670
|
-
| {
|
|
704
|
+
| {
|
|
705
|
+
label: string;
|
|
706
|
+
description: string;
|
|
707
|
+
variant: "row";
|
|
708
|
+
onSelect(kind: NewKind | "upload" | ImportKind): void;
|
|
709
|
+
}
|
|
671
710
|
| { label: string; variant?: "icon"; onSelect(kind: NewKind | "upload"): void };
|
|
672
711
|
|
|
673
712
|
function AddMenu(props: AddMenuProps) {
|
|
@@ -687,7 +726,7 @@ function AddMenu(props: AddMenuProps) {
|
|
|
687
726
|
return (
|
|
688
727
|
<DropdownMenu>
|
|
689
728
|
<DropdownMenuTrigger
|
|
690
|
-
{
|
|
729
|
+
aria-label={props.variant === "row" ? props.description : label}
|
|
691
730
|
className={
|
|
692
731
|
variant === "row"
|
|
693
732
|
? "flex min-w-0 flex-1 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm text-sidebar-foreground/70 outline-none transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 focus-visible:ring-sidebar-ring data-[state=open]:bg-sidebar-accent"
|
package/src/app/app.tsx
CHANGED
|
@@ -1,32 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Outlet } from "@tanstack/react-router";
|
|
2
2
|
import { useRef, useState } from "react";
|
|
3
3
|
import { AppSidebar } from "@/app/app-sidebar/app-sidebar.tsx";
|
|
4
4
|
import { HeaderSearch } from "@/app/header-search/header-search.tsx";
|
|
5
5
|
import { createSidebarPreferences } from "@/app/sidebar-preferences/sidebar-preferences.ts";
|
|
6
|
-
import {
|
|
7
|
-
Breadcrumb,
|
|
8
|
-
BreadcrumbItem,
|
|
9
|
-
BreadcrumbLink,
|
|
10
|
-
BreadcrumbList,
|
|
11
|
-
BreadcrumbPage,
|
|
12
|
-
BreadcrumbSeparator,
|
|
13
|
-
} from "@/components/ui/breadcrumb";
|
|
14
|
-
import { Separator } from "@/components/ui/separator";
|
|
15
6
|
import { SidebarInset, SidebarProvider, SidebarTrigger } from "@/components/ui/sidebar";
|
|
16
7
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
17
8
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
9
|
+
// ⚠️ The breadcrumb is gone (#337). It was two levels deep and the first of them was constant —
|
|
10
|
+
// `Intel > Knowledge`, `Intel > Flows`, `Intel > Tools`. The product name stands in the sidebar
|
|
11
|
+
// anyway, the area is marked there as the active row, and what was left was a path that never grew
|
|
12
|
+
// past two links and carried no navigation that did not already exist. The `Separator` beside the
|
|
13
|
+
// sidebar trigger went with it: it separated the trigger from the breadcrumb, and there is nothing
|
|
14
|
+
// left on that side to separate it from.
|
|
24
15
|
export function App() {
|
|
25
16
|
const i18n = useI18n();
|
|
26
|
-
const pathname = useRouterState({ select: (state) => state.location.pathname });
|
|
27
|
-
const current = sections.find(
|
|
28
|
-
(section) => pathname === section.path || pathname.startsWith(`${section.path}/`),
|
|
29
|
-
);
|
|
30
17
|
|
|
31
18
|
// Read once: the stored preference is the starting point, after which the shell's own state is
|
|
32
19
|
// the truth. Re-reading on every render would fight the drag.
|
|
@@ -73,24 +60,6 @@ export function App() {
|
|
|
73
60
|
<SidebarInset className="min-w-0 overflow-hidden md:border">
|
|
74
61
|
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4">
|
|
75
62
|
<SidebarTrigger className="-ml-1" aria-label={i18n.t("shell.toggleSidebar")} />
|
|
76
|
-
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
|
77
|
-
<Breadcrumb>
|
|
78
|
-
<BreadcrumbList>
|
|
79
|
-
<BreadcrumbItem>
|
|
80
|
-
<BreadcrumbLink asChild>
|
|
81
|
-
<Link to="/nodes">{i18n.t("app.name")}</Link>
|
|
82
|
-
</BreadcrumbLink>
|
|
83
|
-
</BreadcrumbItem>
|
|
84
|
-
{current ? (
|
|
85
|
-
<>
|
|
86
|
-
<BreadcrumbSeparator />
|
|
87
|
-
<BreadcrumbItem>
|
|
88
|
-
<BreadcrumbPage>{i18n.t(current.labelKey)}</BreadcrumbPage>
|
|
89
|
-
</BreadcrumbItem>
|
|
90
|
-
</>
|
|
91
|
-
) : null}
|
|
92
|
-
</BreadcrumbList>
|
|
93
|
-
</Breadcrumb>
|
|
94
63
|
{/* The bar for area-owned actions. A screen renders into the inner box through
|
|
95
64
|
`ActionSlot`; search is the shell's own, because it has to open from every screen
|
|
96
65
|
alike and belongs to none of them. It stays last, against the right edge, and a
|
|
@@ -16,17 +16,24 @@ import { useI18n, useLanguage } from "@/i18n/i18n-context.tsx";
|
|
|
16
16
|
import { languageName } from "@/i18n/i18n-languages/i18n-languages.ts";
|
|
17
17
|
import { type ThemeChoice, ThemeChoices } from "@/theme/theme.ts";
|
|
18
18
|
import { useTheme } from "@/theme/theme-context.tsx";
|
|
19
|
+
import { DeviceZone, deviceZone, ZoneOptions } from "@/time/time.ts";
|
|
20
|
+
import { useTimeZone } from "@/time/time-context.tsx";
|
|
19
21
|
|
|
20
22
|
// The settings that belong to the person rather than to the installation: language and appearance.
|
|
21
23
|
// Both are client state — they live in localStorage, not in the Intel HTTP surface — so they do not
|
|
22
24
|
// belong on a screen with a save button. A choice applies at once; a dialog that previews and only
|
|
23
25
|
// commits on "Save" would carry two states too many for two dropdowns.
|
|
24
26
|
//
|
|
25
|
-
// ⚠️
|
|
26
|
-
//
|
|
27
|
-
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
27
|
+
// ⚠️ The third one, the reader's timezone, is BACK — and the sentence that sent it away is the
|
|
28
|
+
// reason it could come back (#467). It left with the Agents (#388) because it was a suggestion for
|
|
29
|
+
// a schedule and the lens its calendar was read through (#228); with both gone nothing read the
|
|
30
|
+
// value, "and a preference that changes nothing on screen is worse than none — it invites the
|
|
31
|
+
// reader to set it and then contradicts them. Reading times in a chosen zone is a feature, and it
|
|
32
|
+
// would come back as one."
|
|
33
|
+
//
|
|
34
|
+
// It came back as one, and in that order: FIRST every time on screen reads `useDateTime()`, THEN
|
|
35
|
+
// this dropdown exists. Whoever adds a surface that shows a time reads that hook — a second
|
|
36
|
+
// `toLocaleString` would be the old state again, only harder to find.
|
|
30
37
|
//
|
|
31
38
|
// The dialog is opened from outside (the user footer holds the state), because its trigger is a
|
|
32
39
|
// DropdownMenuItem: Radix unmounts the menu content when an item is chosen and would take a dialog
|
|
@@ -40,6 +47,7 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
|
40
47
|
const i18n = useI18n();
|
|
41
48
|
const language = useLanguage();
|
|
42
49
|
const theme = useTheme();
|
|
50
|
+
const zone = useTimeZone();
|
|
43
51
|
|
|
44
52
|
return (
|
|
45
53
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
@@ -95,6 +103,31 @@ export function SettingsDialog({ open, onOpenChange }: SettingsDialogProps) {
|
|
|
95
103
|
</Select>
|
|
96
104
|
</div>
|
|
97
105
|
) : null}
|
|
106
|
+
{zone ? (
|
|
107
|
+
<div className="grid gap-2">
|
|
108
|
+
<label className="text-sm font-medium" htmlFor="settings-timezone">
|
|
109
|
+
{i18n.t("settings.timezone")}
|
|
110
|
+
</label>
|
|
111
|
+
<Select value={zone.current} onValueChange={zone.select}>
|
|
112
|
+
<SelectTrigger id="settings-timezone" className="w-full">
|
|
113
|
+
<SelectValue />
|
|
114
|
+
</SelectTrigger>
|
|
115
|
+
<SelectContent>
|
|
116
|
+
{/* The default is the device's zone and is NAMED as such — not an empty field
|
|
117
|
+
that would look as if nothing were set. */}
|
|
118
|
+
<SelectItem value={DeviceZone}>
|
|
119
|
+
{i18n.t("settings.timezone.device", { zone: deviceZone() })}
|
|
120
|
+
</SelectItem>
|
|
121
|
+
{ZoneOptions.map((option) => (
|
|
122
|
+
<SelectItem key={option} value={option}>
|
|
123
|
+
{option}
|
|
124
|
+
</SelectItem>
|
|
125
|
+
))}
|
|
126
|
+
</SelectContent>
|
|
127
|
+
</Select>
|
|
128
|
+
<p className="text-xs text-muted-foreground">{i18n.t("settings.timezone.hint")}</p>
|
|
129
|
+
</div>
|
|
130
|
+
) : null}
|
|
98
131
|
</div>
|
|
99
132
|
</DialogContent>
|
|
100
133
|
</Dialog>
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
DropdownMenu,
|
|
15
15
|
DropdownMenuContent,
|
|
16
16
|
DropdownMenuItem,
|
|
17
|
+
DropdownMenuLabel,
|
|
17
18
|
DropdownMenuSeparator,
|
|
18
19
|
DropdownMenuTrigger,
|
|
19
20
|
} from "@/components/ui/dropdown-menu";
|
|
@@ -89,6 +90,25 @@ export function UserFooter() {
|
|
|
89
90
|
align="start"
|
|
90
91
|
className="w-(--radix-dropdown-menu-trigger-width) min-w-56"
|
|
91
92
|
>
|
|
93
|
+
{/* #452: the head — who is signed in, above the things they can do. It used to stand
|
|
94
|
+
ONLY in the trigger BELOW the menu, so an open menu pointed at the answer instead of
|
|
95
|
+
giving it. Not a menu item: it is a reading, not an action, so it takes no focus and
|
|
96
|
+
nothing happens when it is pressed. */}
|
|
97
|
+
<DropdownMenuLabel className="font-normal">
|
|
98
|
+
<div className="grid text-left text-sm leading-tight">
|
|
99
|
+
<span className="truncate font-medium">{primary}</span>
|
|
100
|
+
{secondary ? (
|
|
101
|
+
<span className="truncate text-xs text-muted-foreground">{secondary}</span>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
</DropdownMenuLabel>
|
|
105
|
+
{/* ⚠️ The ONE separator, and the only one left (#452). There used to be three, each with
|
|
106
|
+
a good reason of its own: the settings change nothing anybody else sees, the reindex
|
|
107
|
+
belongs to the installation rather than the person, signing out is destructive.
|
|
108
|
+
Together they cut five rows into five groups — and a grouping that separates every
|
|
109
|
+
single row groups nothing. Above it stands who you are, below it what you can do;
|
|
110
|
+
that is the whole distinction the line carries, and it is the same one Gate draws. */}
|
|
111
|
+
<DropdownMenuSeparator />
|
|
92
112
|
<DropdownMenuItem onSelect={() => void navigate({ to: "/tools" })}>
|
|
93
113
|
<Wrench aria-hidden="true" />
|
|
94
114
|
{i18n.t("nav.tools")}
|
|
@@ -99,9 +119,7 @@ export function UserFooter() {
|
|
|
99
119
|
<ArchiveIcon aria-hidden="true" />
|
|
100
120
|
{i18n.t("archive.title")}
|
|
101
121
|
</DropdownMenuItem>
|
|
102
|
-
|
|
103
|
-
{/* Language, appearance and timezone belong to the person, like everything else behind
|
|
104
|
-
this menu — and unlike the two rows above, they change nothing anybody else sees. */}
|
|
122
|
+
{/* Language and appearance belong to the person, like everything else behind this menu. */}
|
|
105
123
|
<DropdownMenuItem onSelect={() => setSettingsOpen(true)}>
|
|
106
124
|
<Settings aria-hidden="true" />
|
|
107
125
|
{i18n.t("settings.title")}
|
|
@@ -109,18 +127,18 @@ export function UserFooter() {
|
|
|
109
127
|
{/* ⚠️ Drawn only for an administrator, and that is a DRAWING decision — `POST
|
|
110
128
|
/nodes/reindex` asks Gate for `intel/admin` itself and would refuse this row's press
|
|
111
129
|
just the same (#416). Hiding it is not the check; it is not offering everybody a
|
|
112
|
-
door they cannot open.
|
|
113
|
-
|
|
130
|
+
door they cannot open.
|
|
131
|
+
⚠️ Its own separator is GONE (#452): it set a single row apart, and that is exactly
|
|
132
|
+
the line this ticket removes. The reason it carried — this one belongs to the
|
|
133
|
+
installation — is true and still not worth a rule of its own on a five-row menu. */}
|
|
114
134
|
{session.data?.isAdmin ? (
|
|
115
|
-
|
|
116
|
-
<
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
{i18n.t("reindex.title")}
|
|
120
|
-
</DropdownMenuItem>
|
|
121
|
-
</>
|
|
135
|
+
<DropdownMenuItem onSelect={() => setReindexOpen(true)}>
|
|
136
|
+
<RefreshCw aria-hidden="true" />
|
|
137
|
+
{i18n.t("reindex.title")}
|
|
138
|
+
</DropdownMenuItem>
|
|
122
139
|
) : null}
|
|
123
|
-
|
|
140
|
+
{/* Signing out stays recognisable as destructive — the COLOUR does that, and it needs no
|
|
141
|
+
line of its own to say it twice. */}
|
|
124
142
|
<DropdownMenuItem
|
|
125
143
|
disabled={logout.isPending}
|
|
126
144
|
onSelect={() => logout.mutate()}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { useQuery } from "@tanstack/react-query";
|
|
2
|
+
import { RouterProvider } from "@tanstack/react-router";
|
|
3
|
+
import { useSyncExternalStore } from "react";
|
|
4
|
+
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
5
|
+
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
|
+
import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
|
|
7
|
+
import type { AppRootDeps, RefusalStore } from "./app-root.types.ts";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The one decision this application makes before it draws anything: **is there a session?**
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ Without a session there is NO SHELL — not the sidebar, not the header, not the search (#456).
|
|
13
|
+
* It used to paint first and ask afterwards: the `RouterProvider` started, the shell stood, the
|
|
14
|
+
* queries went out, and only when the first of them came back 401 did the redirect happen. Between
|
|
15
|
+
* "painted" and "redirected" lay a full network round trip in which a stranger saw the application.
|
|
16
|
+
* A slow answer stretched that window arbitrarily, and a route that fires no query at all never
|
|
17
|
+
* closed it.
|
|
18
|
+
*
|
|
19
|
+
* ⚠️ What is NOT claimed: that data would become visible. The api answers 401 and the lists stay
|
|
20
|
+
* empty. But that one server answer was then the ONLY defence — the client had none. Any surface
|
|
21
|
+
* that later renders something before the first request (a cache, a `localStorage`, an optimistic
|
|
22
|
+
* value) would break it without meaning to. And an application that looks as if one were inside it
|
|
23
|
+
* and contains nothing is the wrong first impression from a product whose business is access
|
|
24
|
+
* control.
|
|
25
|
+
*
|
|
26
|
+
* ⚠️ **This is the file #130 asked for.** The logic sat in `main.tsx`, `main.tsx` had no test, and
|
|
27
|
+
* therefore none of the eleven tests written for #118 ever touched it: *"the entry point is exactly
|
|
28
|
+
* the place where 'this is only wiring' sounds most plausible and costs most."* The decision now
|
|
29
|
+
* lives somewhere a test can call.
|
|
30
|
+
*/
|
|
31
|
+
export function AppRoot({ data, router, refusal }: AppRootDeps) {
|
|
32
|
+
const refused = useSyncExternalStore(refusal.subscribe, refusal.isRefused, refusal.isRefused);
|
|
33
|
+
if (refused) return <SignInRefused />;
|
|
34
|
+
return <SessionGate data={data} router={router} />;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* ⚠️ Its own component, and not merged into `AppRoot`: the hook below may only run once a
|
|
39
|
+
* `QueryClientProvider` is above it, and `refused` has to be answerable without one — the refusal
|
|
40
|
+
* screen is what stands there when nothing else works.
|
|
41
|
+
*/
|
|
42
|
+
function SessionGate({ data, router }: { data: IntelDataProvider; router: AppRootDeps["router"] }) {
|
|
43
|
+
const i18n = useI18n();
|
|
44
|
+
// ⚠️ `retry: false`, deliberately against the client's default policy: this answer is
|
|
45
|
+
// authoritative in both directions. A 401 is not a hiccup to be retried — the provider's
|
|
46
|
+
// `onUnauthorized` is already taking the reader to Gate while this promise rejects — and a retry
|
|
47
|
+
// here would additionally be PAUSED while the tab is unfocused (`packages/ui/CLAUDE.md`), which
|
|
48
|
+
// is how a guard turns into a permanent "Loading" for somebody looking elsewhere.
|
|
49
|
+
//
|
|
50
|
+
// The key is the one `UserFooter` reads, so the shell's own session query is already answered
|
|
51
|
+
// when it mounts. One question, asked once.
|
|
52
|
+
const session = useQuery({
|
|
53
|
+
queryKey: ["session"],
|
|
54
|
+
queryFn: () => data.getSession(),
|
|
55
|
+
retry: false,
|
|
56
|
+
staleTime: Number.POSITIVE_INFINITY,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// ⚠️ Both the waiting and the failing case render NO shell. The failing one is not the same as
|
|
60
|
+
// "not signed in": a 401 has already sent the reader away by the time it lands here, so what is
|
|
61
|
+
// left is a surface that could not be reached at all — and saying that is better than a spinner
|
|
62
|
+
// that never ends. Neither of them draws a sidebar.
|
|
63
|
+
if (session.isPending) {
|
|
64
|
+
return (
|
|
65
|
+
<div className="grid min-h-dvh place-items-center bg-background p-8">
|
|
66
|
+
<p className="text-sm text-muted-foreground">{i18n.t("common.loading")}</p>
|
|
67
|
+
</div>
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
if (session.isError) {
|
|
71
|
+
return (
|
|
72
|
+
<div role="alert" className="grid min-h-dvh place-items-center bg-background p-8">
|
|
73
|
+
<div className="max-w-prose text-center">
|
|
74
|
+
<p className="text-sm text-muted-foreground">{i18n.t("signIn.unreachable")}</p>
|
|
75
|
+
<button
|
|
76
|
+
type="button"
|
|
77
|
+
onClick={() => window.location.reload()}
|
|
78
|
+
className="mt-6 rounded-md border px-4 py-2 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
79
|
+
>
|
|
80
|
+
{i18n.t("common.retry")}
|
|
81
|
+
</button>
|
|
82
|
+
</div>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
return <RouterProvider router={router} />;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* ⚠️ Latching, and that is not an optimisation: once the surface has turned down a freshly issued
|
|
91
|
+
* credential, later 401s say nothing new. Without the latch every request the page still had in
|
|
92
|
+
* flight would notify again, and the screen would flicker once per answer.
|
|
93
|
+
*/
|
|
94
|
+
export function createRefusalStore(): RefusalStore {
|
|
95
|
+
let refused = false;
|
|
96
|
+
const listeners = new Set<() => void>();
|
|
97
|
+
return {
|
|
98
|
+
subscribe(listener) {
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => listeners.delete(listener);
|
|
101
|
+
},
|
|
102
|
+
isRefused: () => refused,
|
|
103
|
+
refuse() {
|
|
104
|
+
if (refused) return;
|
|
105
|
+
refused = true;
|
|
106
|
+
for (const listener of listeners) listener();
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { IntelDataProvider } from "@/data/intel-data-provider/intel-data-provider.types.ts";
|
|
2
|
+
import type { createIntelRouter } from "@/router/router.tsx";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the surface has turned down a freshly issued credential (#118) — a store rather than a
|
|
6
|
+
* module-level `let`, and that is the whole point of #456.
|
|
7
|
+
*
|
|
8
|
+
* The refusal arrives from inside a `fetch`, not from React: the data provider calls
|
|
9
|
+
* `onUnauthorized`, and something on screen has to change because of it. That used to be a
|
|
10
|
+
* `let refused = false` in `main.tsx` with a hand-written repaint — a decision in bare module body,
|
|
11
|
+
* which is exactly what nothing could call and therefore what no test ever touched (#130).
|
|
12
|
+
*/
|
|
13
|
+
export interface RefusalStore {
|
|
14
|
+
subscribe(listener: () => void): () => void;
|
|
15
|
+
isRefused(): boolean;
|
|
16
|
+
refuse(): void;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type IntelRouter = ReturnType<typeof createIntelRouter>;
|
|
20
|
+
|
|
21
|
+
export interface AppRootDeps {
|
|
22
|
+
// ⚠️ The provider is handed in rather than read from the router context: that context only exists
|
|
23
|
+
// INSIDE `RouterProvider`, and the whole point of the gate is to decide before it mounts.
|
|
24
|
+
data: IntelDataProvider;
|
|
25
|
+
router: IntelRouter;
|
|
26
|
+
refusal: RefusalStore;
|
|
27
|
+
}
|
package/src/archive/archive.tsx
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { Flow } from "@anchrd/intel-contract/flow";
|
|
2
2
|
import type { Node } from "@anchrd/intel-contract/node";
|
|
3
3
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
-
import { ArchiveRestore } from "lucide-react";
|
|
4
|
+
import { ArchiveRestore, Trash2 } from "lucide-react";
|
|
5
|
+
import { useState } from "react";
|
|
5
6
|
import { useI18n } from "@/i18n/i18n-context.tsx";
|
|
6
7
|
import { kindIcons } from "@/kind-icon.ts";
|
|
8
|
+
import { Modal } from "@/modal/modal.tsx";
|
|
7
9
|
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
10
|
+
import { useDateTime } from "@/time/time-context.tsx";
|
|
8
11
|
|
|
9
12
|
// One archived thing, whichever side of the tree it came from. The two records stay apart
|
|
10
13
|
// everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
|
|
@@ -16,11 +19,15 @@ interface ArchivedEntry {
|
|
|
16
19
|
archivedAt: string;
|
|
17
20
|
updatedAt: string;
|
|
18
21
|
restore(): Promise<unknown>;
|
|
22
|
+
// Both sides of the tree can be purged (#457) — each through its own door, because node and flow
|
|
23
|
+
// stay separate everywhere else (ADR-0004).
|
|
24
|
+
purge(): Promise<{ purged: true; title: string }>;
|
|
19
25
|
}
|
|
20
26
|
|
|
21
27
|
export function Archive() {
|
|
22
28
|
const { data } = useIntelRouterContext();
|
|
23
29
|
const i18n = useI18n();
|
|
30
|
+
const dateTime = useDateTime();
|
|
24
31
|
const queryClient = useQueryClient();
|
|
25
32
|
|
|
26
33
|
const archived = useQuery({
|
|
@@ -47,6 +54,7 @@ export function Archive() {
|
|
|
47
54
|
archived: false,
|
|
48
55
|
idempotencyKey: crypto.randomUUID(),
|
|
49
56
|
}),
|
|
57
|
+
purge: () => data.purgeNode({ nodeId: node.id }),
|
|
50
58
|
} satisfies ArchivedEntry,
|
|
51
59
|
]
|
|
52
60
|
: [],
|
|
@@ -67,6 +75,7 @@ export function Archive() {
|
|
|
67
75
|
archived: false,
|
|
68
76
|
idempotencyKey: crypto.randomUUID(),
|
|
69
77
|
}),
|
|
78
|
+
purge: () => data.purgeFlow({ flowId: flow.id }),
|
|
70
79
|
} satisfies ArchivedEntry,
|
|
71
80
|
]
|
|
72
81
|
: [],
|
|
@@ -95,6 +104,25 @@ export function Archive() {
|
|
|
95
104
|
},
|
|
96
105
|
});
|
|
97
106
|
|
|
107
|
+
// ⚠️ The confirmation is a real dialog, not a `window.confirm` — it has to name the thing and say
|
|
108
|
+
// that it does not come back. The state lives here because the row underneath disappears the
|
|
109
|
+
// moment the purge goes through.
|
|
110
|
+
const [confirming, setConfirming] = useState<ArchivedEntry | null>(null);
|
|
111
|
+
const purge = useMutation({
|
|
112
|
+
mutationFn: async (entry: ArchivedEntry) => await entry.purge(),
|
|
113
|
+
onSuccess: async () => {
|
|
114
|
+
setConfirming(null);
|
|
115
|
+
await Promise.all([
|
|
116
|
+
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
117
|
+
// The tree and the graph did not show the node, but its DISAPPEARANCE can be visible
|
|
118
|
+
// elsewhere: a reference to it no longer resolves afterwards.
|
|
119
|
+
queryClient.invalidateQueries({ queryKey: ["tree"] }),
|
|
120
|
+
queryClient.invalidateQueries({ queryKey: ["node-graph"] }),
|
|
121
|
+
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
122
|
+
]);
|
|
123
|
+
},
|
|
124
|
+
});
|
|
125
|
+
|
|
98
126
|
return (
|
|
99
127
|
<div className="flex min-h-0 flex-1 flex-col">
|
|
100
128
|
<div className="border-b px-6 py-4">
|
|
@@ -114,6 +142,13 @@ export function Archive() {
|
|
|
114
142
|
{i18n.t("archive.restoreFailed")}
|
|
115
143
|
</p>
|
|
116
144
|
) : null}
|
|
145
|
+
{/* ⚠️ The refusal stands HERE and not in the dialog: it carries the reason — folder not
|
|
146
|
+
empty, a flow still uses it — and it has to stay readable after the dialog is closed. */}
|
|
147
|
+
{purge.isError ? (
|
|
148
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
149
|
+
{i18n.t("archive.purgeFailed")}
|
|
150
|
+
</p>
|
|
151
|
+
) : null}
|
|
117
152
|
{archived.data?.length === 0 ? (
|
|
118
153
|
<p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
|
|
119
154
|
) : null}
|
|
@@ -135,9 +170,14 @@ export function Archive() {
|
|
|
135
170
|
<span className="grid min-w-0 flex-1 leading-tight">
|
|
136
171
|
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
137
172
|
<span className="truncate text-xs text-muted-foreground">
|
|
138
|
-
{i18n.t("archive.archivedAt", { when: entry.archivedAt
|
|
173
|
+
{i18n.t("archive.archivedAt", { when: dateTime.at(entry.archivedAt) })}
|
|
139
174
|
</span>
|
|
140
175
|
</span>
|
|
176
|
+
{/* ⚠️ Both icons stand PERMANENTLY, not only on hover — a deliberate exception to the
|
|
177
|
+
row convention (#457). There are two of them, both rare, and this list is the only
|
|
178
|
+
place they occur: the wall of icons anchrd/gate#237 was written against does not
|
|
179
|
+
arise here. The way back keeps its text; the purge is a bare icon, so that the two
|
|
180
|
+
do not weigh the same. */}
|
|
141
181
|
<button
|
|
142
182
|
type="button"
|
|
143
183
|
disabled={restore.isPending}
|
|
@@ -148,10 +188,44 @@ export function Archive() {
|
|
|
148
188
|
<ArchiveRestore aria-hidden="true" className="size-4" />
|
|
149
189
|
{i18n.t("archive.restoreAction")}
|
|
150
190
|
</button>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
disabled={purge.isPending}
|
|
194
|
+
onClick={() => setConfirming(entry)}
|
|
195
|
+
aria-label={i18n.t("archive.purge", { title: entry.title })}
|
|
196
|
+
className="inline-flex size-8 shrink-0 items-center justify-center rounded-md border text-destructive outline-none hover:bg-destructive/10 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
197
|
+
>
|
|
198
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
199
|
+
</button>
|
|
151
200
|
</li>
|
|
152
201
|
))}
|
|
153
202
|
</ul>
|
|
154
203
|
</div>
|
|
204
|
+
{confirming ? (
|
|
205
|
+
<Modal title={i18n.t("archive.purge.title")} close={() => setConfirming(null)}>
|
|
206
|
+
<p className="text-sm text-muted-foreground">
|
|
207
|
+
{i18n.t("archive.purge.body", { title: confirming.title })}
|
|
208
|
+
</p>
|
|
209
|
+
<div className="mt-6 flex justify-end gap-2">
|
|
210
|
+
<button
|
|
211
|
+
type="button"
|
|
212
|
+
onClick={() => setConfirming(null)}
|
|
213
|
+
className="inline-flex h-8 items-center rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring"
|
|
214
|
+
>
|
|
215
|
+
{i18n.t("archive.purge.cancel")}
|
|
216
|
+
</button>
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
disabled={purge.isPending}
|
|
220
|
+
onClick={() => purge.mutate(confirming)}
|
|
221
|
+
className="inline-flex h-8 items-center gap-2 rounded-md bg-destructive px-3 text-sm font-medium text-destructive-foreground outline-none hover:bg-destructive/90 focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
222
|
+
>
|
|
223
|
+
<Trash2 aria-hidden="true" className="size-4" />
|
|
224
|
+
{i18n.t("archive.purge.confirm")}
|
|
225
|
+
</button>
|
|
226
|
+
</div>
|
|
227
|
+
</Modal>
|
|
228
|
+
) : null}
|
|
155
229
|
</div>
|
|
156
230
|
);
|
|
157
231
|
}
|
|
@@ -12,6 +12,8 @@ import {
|
|
|
12
12
|
ListFlowsInput,
|
|
13
13
|
PreviewFlowPublishInput,
|
|
14
14
|
PublishFlowInput,
|
|
15
|
+
PurgeFlowInput,
|
|
16
|
+
PurgeFlowResult,
|
|
15
17
|
RelationGraph,
|
|
16
18
|
RelationGraphInput,
|
|
17
19
|
SaveFlowVersionInput,
|
|
@@ -36,6 +38,8 @@ import {
|
|
|
36
38
|
NodeList,
|
|
37
39
|
NodeTable,
|
|
38
40
|
NodeVersionList,
|
|
41
|
+
PurgeNodeInput,
|
|
42
|
+
PurgeNodeResult,
|
|
39
43
|
ReindexResult,
|
|
40
44
|
ResolveNodeLinksInput,
|
|
41
45
|
ResolveNodeLinksResult,
|
|
@@ -326,6 +330,18 @@ export function createIntelDataProvider(
|
|
|
326
330
|
body: JSON.stringify(parsed),
|
|
327
331
|
});
|
|
328
332
|
},
|
|
333
|
+
async purgeNode(input) {
|
|
334
|
+
const parsed = PurgeNodeInput.parse(input);
|
|
335
|
+
return await request(`/nodes/${encodeURIComponent(parsed.nodeId)}`, PurgeNodeResult, {
|
|
336
|
+
method: "DELETE",
|
|
337
|
+
});
|
|
338
|
+
},
|
|
339
|
+
async purgeFlow(input) {
|
|
340
|
+
const parsed = PurgeFlowInput.parse(input);
|
|
341
|
+
return await request(`/flows/${encodeURIComponent(parsed.flowId)}`, PurgeFlowResult, {
|
|
342
|
+
method: "DELETE",
|
|
343
|
+
});
|
|
344
|
+
},
|
|
329
345
|
async searchNodes(input) {
|
|
330
346
|
return await request("/nodes/search", SearchResult, {
|
|
331
347
|
method: "POST",
|