@anchrd/intel-ui 0.8.7 → 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
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
|
|
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/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 });
|