@anchrd/intel-ui 0.8.6 → 0.8.8
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/user-footer/user-footer.tsx +7 -1
- package/src/archive/archive.tsx +154 -0
- package/src/data/intel-data-provider/intel-data-provider.ts +5 -0
- package/src/data/sign-in/sign-in.ts +30 -0
- package/src/i18n/en.json +8 -0
- package/src/main.tsx +5 -8
- package/src/router/router.tsx +13 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
2
2
|
import { useNavigate, useRouterState } from "@tanstack/react-router";
|
|
3
|
-
import { ChevronsUpDown, LogOut, User, Wrench } from "lucide-react";
|
|
3
|
+
import { Archive as ArchiveIcon, ChevronsUpDown, LogOut, User, Wrench } from "lucide-react";
|
|
4
4
|
import {
|
|
5
5
|
DropdownMenu,
|
|
6
6
|
DropdownMenuContent,
|
|
@@ -76,6 +76,12 @@ export function UserFooter() {
|
|
|
76
76
|
<Wrench aria-hidden="true" />
|
|
77
77
|
{i18n.t("nav.tools")}
|
|
78
78
|
</DropdownMenuItem>
|
|
79
|
+
{/* The archive lives here rather than in the tree: it is not a place things are IN,
|
|
80
|
+
it is where they went when they left the tree (#113). */}
|
|
81
|
+
<DropdownMenuItem onSelect={() => void navigate({ to: "/archive" })}>
|
|
82
|
+
<ArchiveIcon aria-hidden="true" />
|
|
83
|
+
{i18n.t("archive.title")}
|
|
84
|
+
</DropdownMenuItem>
|
|
79
85
|
<DropdownMenuSeparator />
|
|
80
86
|
<DropdownMenuItem
|
|
81
87
|
disabled={logout.isPending}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import type { Flow, KnowledgeNode } from "@anchrd/intel-contract";
|
|
2
|
+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
3
|
+
import { ArchiveRestore } from "lucide-react";
|
|
4
|
+
import { kindIcons } from "@/kind-icon.ts";
|
|
5
|
+
import { useIntelRouterContext } from "@/router/router-context.ts";
|
|
6
|
+
|
|
7
|
+
// One archived thing, whichever side of the tree it came from. The two records stay apart
|
|
8
|
+
// everywhere else (ADR-0004); here they are one list because "what did I throw away" is one
|
|
9
|
+
// question, and an archive split in two would make somebody ask it twice.
|
|
10
|
+
interface ArchivedEntry {
|
|
11
|
+
id: string;
|
|
12
|
+
title: string;
|
|
13
|
+
kind: KnowledgeNode["kind"] | "flow";
|
|
14
|
+
archivedAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
restore(): Promise<unknown>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function Archive() {
|
|
20
|
+
const { data, i18n } = useIntelRouterContext();
|
|
21
|
+
const queryClient = useQueryClient();
|
|
22
|
+
|
|
23
|
+
const archived = useQuery({
|
|
24
|
+
queryKey: ["archive"],
|
|
25
|
+
queryFn: async (): Promise<ArchivedEntry[]> => {
|
|
26
|
+
const [knowledge, flows] = await Promise.all([
|
|
27
|
+
data.listKnowledge({ archivedOnly: true }),
|
|
28
|
+
data.listFlows({ archivedOnly: true }),
|
|
29
|
+
]);
|
|
30
|
+
const entries: ArchivedEntry[] = [
|
|
31
|
+
...knowledge.items.flatMap((node) =>
|
|
32
|
+
node.archivedAt
|
|
33
|
+
? [
|
|
34
|
+
{
|
|
35
|
+
id: node.id,
|
|
36
|
+
title: node.title,
|
|
37
|
+
kind: node.kind,
|
|
38
|
+
archivedAt: node.archivedAt,
|
|
39
|
+
updatedAt: node.updatedAt,
|
|
40
|
+
restore: () =>
|
|
41
|
+
data.archiveKnowledge({
|
|
42
|
+
nodeId: node.id,
|
|
43
|
+
baseUpdatedAt: node.updatedAt,
|
|
44
|
+
archived: false,
|
|
45
|
+
idempotencyKey: crypto.randomUUID(),
|
|
46
|
+
}),
|
|
47
|
+
} satisfies ArchivedEntry,
|
|
48
|
+
]
|
|
49
|
+
: [],
|
|
50
|
+
),
|
|
51
|
+
...flows.items.flatMap((flow: Flow) =>
|
|
52
|
+
flow.archivedAt
|
|
53
|
+
? [
|
|
54
|
+
{
|
|
55
|
+
id: flow.id,
|
|
56
|
+
title: flow.title,
|
|
57
|
+
kind: "flow" as const,
|
|
58
|
+
archivedAt: flow.archivedAt,
|
|
59
|
+
updatedAt: flow.updatedAt,
|
|
60
|
+
restore: () =>
|
|
61
|
+
data.archiveFlow({
|
|
62
|
+
flowId: flow.id,
|
|
63
|
+
baseUpdatedAt: flow.updatedAt,
|
|
64
|
+
archived: false,
|
|
65
|
+
idempotencyKey: crypto.randomUUID(),
|
|
66
|
+
}),
|
|
67
|
+
} satisfies ArchivedEntry,
|
|
68
|
+
]
|
|
69
|
+
: [],
|
|
70
|
+
),
|
|
71
|
+
];
|
|
72
|
+
// Newest first, across both kinds: the thing somebody is looking for is almost always the
|
|
73
|
+
// thing they just archived.
|
|
74
|
+
return entries.sort((a, b) => b.archivedAt.localeCompare(a.archivedAt));
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// ⚠️ `baseUpdatedAt` from the row that is on screen, exactly as archiving used it. Restoring is
|
|
79
|
+
// a change like any other and takes the same conflict route — without it, two people tidying up
|
|
80
|
+
// at once would silently overwrite each other.
|
|
81
|
+
const restore = useMutation({
|
|
82
|
+
mutationFn: async (entry: ArchivedEntry) => await entry.restore(),
|
|
83
|
+
onSuccess: async () => {
|
|
84
|
+
await Promise.all([
|
|
85
|
+
queryClient.invalidateQueries({ queryKey: ["archive"] }),
|
|
86
|
+
// The tree gets it back, so every level has to be asked again — the entry may sit
|
|
87
|
+
// anywhere, and the archive does not know where.
|
|
88
|
+
queryClient.invalidateQueries({ queryKey: ["tree"] }),
|
|
89
|
+
queryClient.invalidateQueries({ queryKey: ["knowledge-graph"] }),
|
|
90
|
+
queryClient.invalidateQueries({ queryKey: ["flows"] }),
|
|
91
|
+
]);
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<div className="flex min-h-0 flex-1 flex-col">
|
|
97
|
+
<div className="border-b px-6 py-4">
|
|
98
|
+
<h1 className="text-lg font-medium text-foreground">{i18n.t("archive.title")}</h1>
|
|
99
|
+
<p className="mt-1 max-w-prose text-sm text-muted-foreground">
|
|
100
|
+
{i18n.t("archive.description")}
|
|
101
|
+
</p>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
|
|
104
|
+
{archived.isError ? (
|
|
105
|
+
<p role="alert" className="text-sm text-destructive">
|
|
106
|
+
{i18n.t("archive.failed")}
|
|
107
|
+
</p>
|
|
108
|
+
) : null}
|
|
109
|
+
{restore.isError ? (
|
|
110
|
+
<p role="alert" className="mb-4 text-sm text-destructive">
|
|
111
|
+
{i18n.t("archive.restoreFailed")}
|
|
112
|
+
</p>
|
|
113
|
+
) : null}
|
|
114
|
+
{archived.data?.length === 0 ? (
|
|
115
|
+
<p className="text-sm text-muted-foreground">{i18n.t("archive.empty")}</p>
|
|
116
|
+
) : null}
|
|
117
|
+
<ul className="grid gap-1">
|
|
118
|
+
{(archived.data ?? []).map((entry) => (
|
|
119
|
+
<li
|
|
120
|
+
key={`${entry.kind}-${entry.id}`}
|
|
121
|
+
className="flex items-center gap-3 rounded-md border px-3 py-2"
|
|
122
|
+
>
|
|
123
|
+
{(() => {
|
|
124
|
+
// The same map the tree and the folder table draw from — a second one here would
|
|
125
|
+
// drift the first time a kind is added.
|
|
126
|
+
const Icon = kindIcons[entry.kind];
|
|
127
|
+
return (
|
|
128
|
+
<Icon aria-hidden="true" className="size-4 shrink-0 text-muted-foreground" />
|
|
129
|
+
);
|
|
130
|
+
})()}
|
|
131
|
+
<span className="sr-only">{i18n.t(`knowledge.kind.${entry.kind}`)}</span>
|
|
132
|
+
<span className="grid min-w-0 flex-1 leading-tight">
|
|
133
|
+
<span className="truncate text-sm font-medium">{entry.title}</span>
|
|
134
|
+
<span className="truncate text-xs text-muted-foreground">
|
|
135
|
+
{i18n.t("archive.archivedAt", { when: entry.archivedAt.slice(0, 10) })}
|
|
136
|
+
</span>
|
|
137
|
+
</span>
|
|
138
|
+
<button
|
|
139
|
+
type="button"
|
|
140
|
+
disabled={restore.isPending}
|
|
141
|
+
onClick={() => restore.mutate(entry)}
|
|
142
|
+
aria-label={i18n.t("archive.restore", { title: entry.title })}
|
|
143
|
+
className="inline-flex h-8 shrink-0 items-center gap-2 rounded-md border px-3 text-sm font-medium outline-none hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-60"
|
|
144
|
+
>
|
|
145
|
+
<ArchiveRestore aria-hidden="true" className="size-4" />
|
|
146
|
+
{i18n.t("archive.restoreAction")}
|
|
147
|
+
</button>
|
|
148
|
+
</li>
|
|
149
|
+
))}
|
|
150
|
+
</ul>
|
|
151
|
+
</div>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
@@ -165,6 +165,7 @@ export function createIntelDataProvider(
|
|
|
165
165
|
// Same rule as `parentId`: only the asked-for case is sent. The default is the server's, and a
|
|
166
166
|
// provider that spells it out anyway would have to be changed whenever the server's is.
|
|
167
167
|
if (query.includeArchived) params.set("includeArchived", "true");
|
|
168
|
+
if (query.archivedOnly) params.set("archivedOnly", "true");
|
|
168
169
|
const search = params.toString();
|
|
169
170
|
return await request(`/flows${search ? `?${search}` : ""}`, FlowList);
|
|
170
171
|
}
|
|
@@ -175,6 +176,10 @@ export function createIntelDataProvider(
|
|
|
175
176
|
const query = ListKnowledgeNodesInput.parse(input);
|
|
176
177
|
const params = new URLSearchParams({ includeArchived: String(query.includeArchived) });
|
|
177
178
|
if (query.parentId !== null) params.set("parentId", query.parentId);
|
|
179
|
+
// Only the asked-for case travels: `archivedOnly` overrides the folder on the server, so
|
|
180
|
+
// sending it as `false` on every tree read would be noise on the one request it never applies
|
|
181
|
+
// to (#113).
|
|
182
|
+
if (query.archivedOnly) params.set("archivedOnly", "true");
|
|
178
183
|
return await request(`/knowledge?${params}`, KnowledgeNodeList);
|
|
179
184
|
}
|
|
180
185
|
|
|
@@ -65,3 +65,33 @@ export function createBrowserSignIn(loginPath: (returnTo?: string) => string): S
|
|
|
65
65
|
loginPath,
|
|
66
66
|
});
|
|
67
67
|
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What to do with a 401, across all of them at once (#130).
|
|
71
|
+
*
|
|
72
|
+
* ⚠️ The reason this is a guard and not a call to `attempt()` at each 401: a first load without a
|
|
73
|
+
* session fires several requests together — session, tree, flows — and every one of them answers
|
|
74
|
+
* 401. The first starts the redirect; the rest arrive while the browser is still on this page,
|
|
75
|
+
* find the marker the first one just wrote, and read it as "we already tried, this is a loop". It
|
|
76
|
+
* is not — it is this very sign-in, still in flight.
|
|
77
|
+
*
|
|
78
|
+
* Without `leaving`, the interface refuses itself before it can leave, and nobody reaches the
|
|
79
|
+
* login at all. That is worse than the loop this whole mechanism exists to prevent: a loop still
|
|
80
|
+
* eventually shows a login, a self-refusal never does.
|
|
81
|
+
*/
|
|
82
|
+
export function createSignInGuard(signIn: SignIn, onRefused: () => void): () => void {
|
|
83
|
+
let leaving = false;
|
|
84
|
+
// Latches for its own reason: once the surface has turned down a freshly issued credential,
|
|
85
|
+
// later 401s say nothing new, and repainting for each would flicker the screen once per request
|
|
86
|
+
// the page had in flight.
|
|
87
|
+
let refused = false;
|
|
88
|
+
return () => {
|
|
89
|
+
if (leaving || refused) return;
|
|
90
|
+
if (signIn.attempt()) {
|
|
91
|
+
leaving = true;
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
refused = true;
|
|
95
|
+
onRefused();
|
|
96
|
+
};
|
|
97
|
+
}
|
package/src/i18n/en.json
CHANGED
|
@@ -73,6 +73,14 @@
|
|
|
73
73
|
"resource.rename": "Rename",
|
|
74
74
|
"resource.renameTitle": "Rename {title}",
|
|
75
75
|
"resource.archive": "Archive",
|
|
76
|
+
"archive.title": "Archive",
|
|
77
|
+
"archive.description": "Everything you archived, newest first. Restoring puts an entry back where it was filed.",
|
|
78
|
+
"archive.restore": "Restore {title}",
|
|
79
|
+
"archive.restoreAction": "Restore",
|
|
80
|
+
"archive.empty": "Nothing is archived.",
|
|
81
|
+
"archive.failed": "The archive could not be read. Check your access and try again.",
|
|
82
|
+
"archive.restoreFailed": "It was not restored. Somebody else may have changed it — reload and try again.",
|
|
83
|
+
"archive.archivedAt": "Archived {when}",
|
|
76
84
|
"resource.conflict": "It was not changed: somebody else changed this entry first. Reload it and try again.",
|
|
77
85
|
"resource.forbidden": "It was not changed: you may not change this entry.",
|
|
78
86
|
"resource.failed": "The change was not saved. Reload the latest version and try again.",
|
package/src/main.tsx
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
createIntelDataProvider,
|
|
7
7
|
loginPath,
|
|
8
8
|
} from "@/data/intel-data-provider/intel-data-provider.ts";
|
|
9
|
-
import { createBrowserSignIn } from "@/data/sign-in/sign-in.ts";
|
|
9
|
+
import { createBrowserSignIn, createSignInGuard } from "@/data/sign-in/sign-in.ts";
|
|
10
10
|
import { createI18n } from "@/i18n/i18n.ts";
|
|
11
11
|
import { createIntelRouter } from "@/router/router.tsx";
|
|
12
12
|
import { SignInRefused } from "@/sign-in-refused/sign-in-refused.tsx";
|
|
@@ -23,18 +23,15 @@ const signIn = createBrowserSignIn(loginPath);
|
|
|
23
23
|
const mounted = createRoot(root);
|
|
24
24
|
const queryClient = new QueryClient();
|
|
25
25
|
|
|
26
|
-
//
|
|
27
|
-
// refusal arrives from inside a fetch rather than from React.
|
|
28
|
-
// has turned down a freshly issued credential, later 401s say nothing new, and re-painting for each
|
|
29
|
-
// one would flicker the screen as many times as the page had requests in flight (#118).
|
|
26
|
+
// The shell is painted from a function because a refused sign-in has to replace it, and that
|
|
27
|
+
// refusal arrives from inside a fetch rather than from React.
|
|
30
28
|
let refused = false;
|
|
31
29
|
|
|
32
30
|
const data = createIntelDataProvider({
|
|
33
|
-
onUnauthorized: () => {
|
|
34
|
-
if (signIn.attempt() || refused) return;
|
|
31
|
+
onUnauthorized: createSignInGuard(signIn, () => {
|
|
35
32
|
refused = true;
|
|
36
33
|
paint();
|
|
37
|
-
},
|
|
34
|
+
}),
|
|
38
35
|
});
|
|
39
36
|
const router = createIntelRouter({ data, i18n });
|
|
40
37
|
|
package/src/router/router.tsx
CHANGED
|
@@ -27,13 +27,25 @@ const flowsRoute = createRoute({
|
|
|
27
27
|
validateSearch: selectionSearch,
|
|
28
28
|
component: lazyRouteComponent(() => import("@/flows/flows.tsx"), "Flows"),
|
|
29
29
|
});
|
|
30
|
+
const archiveRoute = createRoute({
|
|
31
|
+
getParentRoute: () => rootRoute,
|
|
32
|
+
path: "/archive",
|
|
33
|
+
validateSearch: selectionSearch,
|
|
34
|
+
component: lazyRouteComponent(() => import("@/archive/archive.tsx"), "Archive"),
|
|
35
|
+
});
|
|
30
36
|
const toolsRoute = createRoute({
|
|
31
37
|
getParentRoute: () => rootRoute,
|
|
32
38
|
path: "/tools",
|
|
33
39
|
validateSearch: selectionSearch,
|
|
34
40
|
component: lazyRouteComponent(() => import("@/tools/tools.tsx"), "Tools"),
|
|
35
41
|
});
|
|
36
|
-
const routeTree = rootRoute.addChildren([
|
|
42
|
+
const routeTree = rootRoute.addChildren([
|
|
43
|
+
redirectRoute,
|
|
44
|
+
knowledgeRoute,
|
|
45
|
+
flowsRoute,
|
|
46
|
+
toolsRoute,
|
|
47
|
+
archiveRoute,
|
|
48
|
+
]);
|
|
37
49
|
|
|
38
50
|
export function createIntelRouter(context: RouterContext) {
|
|
39
51
|
return createRouter({ routeTree, context });
|