cortena-ui 1.4.2 → 1.5.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/LICENSE +7 -0
- package/README.md +149 -1
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +349 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +58 -0
- package/dist/agent-chat/session.js +249 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/store.d.ts +67 -0
- package/dist/agent-chat/store.js +548 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +187 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +10 -0
- package/dist/agent-chat.js +10 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +143 -0
- package/dist/components/agent-chat.js +578 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +2 -2
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +16 -4
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +433 -0
- package/src/agent-chat/session.ts +392 -0
- package/src/agent-chat/store.ts +738 -0
- package/src/agent-chat/types.ts +213 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +843 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +113 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type * as React from "react";
|
|
4
|
+
import { Alert, AlertDescription } from "@/components/alert";
|
|
5
|
+
import { Button } from "@/components/button";
|
|
6
|
+
import { AuthScreen } from "@/components/login-screen";
|
|
7
|
+
import { Spinner } from "@/components/spinner";
|
|
8
|
+
import { cn } from "@/lib/cn";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ConsentScreen — the OAuth authorize page an MCP client lands on
|
|
12
|
+
* (how-to-create-a-cortena-extension §7.4, audit rule P-08, decision DESIGN-D1).
|
|
13
|
+
*
|
|
14
|
+
* It is the consent *variant* of the login screen rather than a screen of its
|
|
15
|
+
* own: same frame, same card, same tokens, same brand row. That is the whole
|
|
16
|
+
* requirement. An MCP client opens a browser onto a page nobody chose to visit,
|
|
17
|
+
* and if that page looks like a different product the only honest reading is
|
|
18
|
+
* that something has gone wrong. Sharing `AuthScreen` with `LoginScreen` is
|
|
19
|
+
* what makes the two impossible to drift apart.
|
|
20
|
+
*
|
|
21
|
+
* It replaces the hand-written HTML cortena-auth serves today
|
|
22
|
+
* (`src/routes/oauth-login.ts`, `renderConsent`), and the props are that page's
|
|
23
|
+
* contents: the client asking, the scopes it wants in plain language, who the
|
|
24
|
+
* user is signed in as, and Allow / Deny. Props-only — the flow id, the CSRF
|
|
25
|
+
* binding cookie, the double-submit guard and the redirect all stay on the
|
|
26
|
+
* server, where they already work.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export interface ConsentClient {
|
|
30
|
+
/** The registered client name — "Claude Code". Never the raw client id. */
|
|
31
|
+
name: string;
|
|
32
|
+
/**
|
|
33
|
+
* The client's own logo, when it registered one. **https only** — the value
|
|
34
|
+
* is a third party's, and this is the screen on which a user decides whether
|
|
35
|
+
* to give that third party access, so `AuthScreen` drops a `data:`, `blob:`
|
|
36
|
+
* or `http:` URL and draws the mark instead (`httpsLogoUrl`).
|
|
37
|
+
*/
|
|
38
|
+
logoUrl?: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface ConsentScope {
|
|
42
|
+
/** The raw scope string, `task:read`. Shown, small, under the label. */
|
|
43
|
+
id: string;
|
|
44
|
+
/** The plain-language gloss — cortena-auth's `glossFor(scope)`. */
|
|
45
|
+
label: string;
|
|
46
|
+
/** One sentence on what granting it actually allows. */
|
|
47
|
+
description?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface ConsentUser {
|
|
51
|
+
name: string;
|
|
52
|
+
email: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface ConsentScreenProps extends Omit<React.ComponentProps<"div">, "onSubmit"> {
|
|
56
|
+
client: ConsentClient;
|
|
57
|
+
/**
|
|
58
|
+
* The requested scopes, already glossed. Rendered in the order given, which
|
|
59
|
+
* is the order cortena-auth resolved them in — not re-sorted here, so the
|
|
60
|
+
* screen and the granted token read the same way.
|
|
61
|
+
*/
|
|
62
|
+
scopes: readonly ConsentScope[];
|
|
63
|
+
/** Who is about to grant this. Shown, because consent is personal. */
|
|
64
|
+
user: ConsentUser;
|
|
65
|
+
onAllow?: () => void;
|
|
66
|
+
onDeny?: () => void;
|
|
67
|
+
/** Disables both decisions and marks Allow busy. One decision per page. */
|
|
68
|
+
loading?: boolean;
|
|
69
|
+
/** A failed decision, in the user's words. */
|
|
70
|
+
error?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function ConsentScreen({
|
|
74
|
+
className,
|
|
75
|
+
client,
|
|
76
|
+
scopes,
|
|
77
|
+
user,
|
|
78
|
+
onAllow,
|
|
79
|
+
onDeny,
|
|
80
|
+
loading,
|
|
81
|
+
error,
|
|
82
|
+
...props
|
|
83
|
+
}: ConsentScreenProps) {
|
|
84
|
+
return (
|
|
85
|
+
<AuthScreen
|
|
86
|
+
data-slot="consent-screen"
|
|
87
|
+
data-loading={loading ? "" : undefined}
|
|
88
|
+
className={className}
|
|
89
|
+
brand={{ name: client.name, logoUrl: client.logoUrl }}
|
|
90
|
+
title={`${client.name} wants access`}
|
|
91
|
+
subtitle="Review what it is asking for before you allow it."
|
|
92
|
+
notices={
|
|
93
|
+
error ? (
|
|
94
|
+
<Alert variant="destructive" data-slot="consent-screen-error">
|
|
95
|
+
<AlertDescription className="text-[color:var(--ds-foreground)]">
|
|
96
|
+
{error}
|
|
97
|
+
</AlertDescription>
|
|
98
|
+
</Alert>
|
|
99
|
+
) : null
|
|
100
|
+
}
|
|
101
|
+
footer={
|
|
102
|
+
<span data-slot="consent-screen-footer">
|
|
103
|
+
You can revoke this access at any time from your Cortena settings.
|
|
104
|
+
</span>
|
|
105
|
+
}
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
<div
|
|
109
|
+
data-slot="consent-screen-user"
|
|
110
|
+
className={cn(
|
|
111
|
+
"flex flex-col gap-0.5 rounded-[var(--ds-radius-md)] bg-[var(--ds-muted)] px-3.5 py-3",
|
|
112
|
+
"text-[length:var(--ds-text-caption-lg)]",
|
|
113
|
+
"leading-[var(--ds-text-caption-lg--line-height)]",
|
|
114
|
+
)}
|
|
115
|
+
>
|
|
116
|
+
<span className="font-medium text-[color:var(--ds-foreground)]">
|
|
117
|
+
Signed in as {user.name}
|
|
118
|
+
</span>
|
|
119
|
+
<span className="text-[color:var(--ds-muted-foreground)]">{user.email}</span>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<div data-slot="consent-screen-scopes" className="flex flex-col gap-2">
|
|
123
|
+
<p
|
|
124
|
+
className={cn(
|
|
125
|
+
"text-[length:var(--ds-text-caption)]",
|
|
126
|
+
"leading-[var(--ds-text-caption--line-height)]",
|
|
127
|
+
"text-[color:var(--ds-muted-foreground)]",
|
|
128
|
+
)}
|
|
129
|
+
>
|
|
130
|
+
{client.name} will be able to:
|
|
131
|
+
</p>
|
|
132
|
+
{/* An empty list is a real state: a client can ask for no scopes at
|
|
133
|
+
all, and saying so is better than an empty box the user has to
|
|
134
|
+
interpret. */}
|
|
135
|
+
{scopes.length === 0 ? (
|
|
136
|
+
<p
|
|
137
|
+
data-slot="consent-screen-no-scopes"
|
|
138
|
+
className={cn(
|
|
139
|
+
"text-[length:var(--ds-text-caption-lg)]",
|
|
140
|
+
"leading-[var(--ds-text-caption-lg--line-height)]",
|
|
141
|
+
"text-[color:var(--ds-muted-foreground)]",
|
|
142
|
+
)}
|
|
143
|
+
>
|
|
144
|
+
Nothing beyond confirming who you are.
|
|
145
|
+
</p>
|
|
146
|
+
) : (
|
|
147
|
+
<ul className="flex list-none flex-col gap-2.5 p-0">
|
|
148
|
+
{scopes.map((scope) => (
|
|
149
|
+
<li
|
|
150
|
+
key={scope.id}
|
|
151
|
+
data-slot="consent-scope"
|
|
152
|
+
data-scope={scope.id}
|
|
153
|
+
className="flex gap-2.5"
|
|
154
|
+
>
|
|
155
|
+
<CheckGlyph />
|
|
156
|
+
<span className="flex min-w-0 flex-col gap-0.5">
|
|
157
|
+
<span
|
|
158
|
+
className={cn(
|
|
159
|
+
"font-medium text-[length:var(--ds-text-caption-lg)]",
|
|
160
|
+
"leading-[var(--ds-text-caption-lg--line-height)]",
|
|
161
|
+
"text-[color:var(--ds-foreground)]",
|
|
162
|
+
)}
|
|
163
|
+
>
|
|
164
|
+
{scope.label}
|
|
165
|
+
</span>
|
|
166
|
+
{scope.description ? (
|
|
167
|
+
<span
|
|
168
|
+
className={cn(
|
|
169
|
+
"text-[length:var(--ds-text-caption)]",
|
|
170
|
+
"leading-[var(--ds-text-caption--line-height)]",
|
|
171
|
+
"text-[color:var(--ds-muted-foreground)]",
|
|
172
|
+
)}
|
|
173
|
+
>
|
|
174
|
+
{scope.description}
|
|
175
|
+
</span>
|
|
176
|
+
) : null}
|
|
177
|
+
<span
|
|
178
|
+
className={cn(
|
|
179
|
+
"font-mono text-[length:var(--ds-text-caption-sm)]",
|
|
180
|
+
"text-[color:var(--ds-text-tertiary)]",
|
|
181
|
+
)}
|
|
182
|
+
>
|
|
183
|
+
{scope.id}
|
|
184
|
+
</span>
|
|
185
|
+
</span>
|
|
186
|
+
</li>
|
|
187
|
+
))}
|
|
188
|
+
</ul>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
{/* Allow first and filled, Deny second and quiet — but both are real
|
|
193
|
+
buttons of the same size, so declining is never the harder click. */}
|
|
194
|
+
<div data-slot="consent-screen-actions" className="flex flex-col gap-2.5">
|
|
195
|
+
<Button
|
|
196
|
+
type="button"
|
|
197
|
+
size="lg"
|
|
198
|
+
disabled={loading}
|
|
199
|
+
data-decision="allow"
|
|
200
|
+
onClick={onAllow}
|
|
201
|
+
>
|
|
202
|
+
{loading ? <Spinner tone="inverse" /> : null}
|
|
203
|
+
{loading ? "Allowing…" : "Allow"}
|
|
204
|
+
</Button>
|
|
205
|
+
<Button
|
|
206
|
+
type="button"
|
|
207
|
+
variant="secondary"
|
|
208
|
+
size="lg"
|
|
209
|
+
disabled={loading}
|
|
210
|
+
data-decision="deny"
|
|
211
|
+
onClick={onDeny}
|
|
212
|
+
>
|
|
213
|
+
Deny
|
|
214
|
+
</Button>
|
|
215
|
+
</div>
|
|
216
|
+
</AuthScreen>
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** The scope bullet. Inline rather than a lucide import: one icon, one size. */
|
|
221
|
+
function CheckGlyph() {
|
|
222
|
+
return (
|
|
223
|
+
<svg
|
|
224
|
+
viewBox="0 0 24 24"
|
|
225
|
+
fill="none"
|
|
226
|
+
stroke="currentColor"
|
|
227
|
+
strokeWidth="2"
|
|
228
|
+
strokeLinecap="round"
|
|
229
|
+
strokeLinejoin="round"
|
|
230
|
+
aria-hidden
|
|
231
|
+
focusable="false"
|
|
232
|
+
className="mt-0.5 size-4 shrink-0 text-[color:var(--ds-primary)]"
|
|
233
|
+
>
|
|
234
|
+
<path d="m4 12.5 5 5L20 6.5" />
|
|
235
|
+
</svg>
|
|
236
|
+
);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export { ConsentScreen };
|
|
@@ -44,8 +44,23 @@ const VIRTUALIZE_ABOVE = 100;
|
|
|
44
44
|
const DEFAULT_VIRTUAL_HEIGHT = 600;
|
|
45
45
|
const INFINITE_THRESHOLD_PX = 240;
|
|
46
46
|
|
|
47
|
+
/** What a `ref` on `DataTable` hands back. */
|
|
48
|
+
export interface DataTableHandle<Row extends RowData> {
|
|
49
|
+
/**
|
|
50
|
+
* Refetch the page on screen through the same query the auto-refresh
|
|
51
|
+
* interval drives — never a second fetch path. A table subscribed to a
|
|
52
|
+
* notification stream calls this from the stream and leaves the interval as
|
|
53
|
+
* the fallback for when the stream is unavailable.
|
|
54
|
+
*/
|
|
55
|
+
refresh: () => void;
|
|
56
|
+
/** Everything else the table knows, for a caller that needs more than a refresh. */
|
|
57
|
+
instance: DataTableInstance<Row>;
|
|
58
|
+
}
|
|
59
|
+
|
|
47
60
|
export interface DataTableViewProps<Row extends RowData> {
|
|
48
61
|
className?: string;
|
|
62
|
+
/** Imperative handle: `ref.current.refresh()`. */
|
|
63
|
+
ref?: React.Ref<DataTableHandle<Row>>;
|
|
49
64
|
/** Height of the scrolling body. Required for virtualisation to mean anything; defaults to 600px when virtualised. */
|
|
50
65
|
maxHeight?: number | string;
|
|
51
66
|
/** Force virtualisation on or off; undefined virtualises above 100 rows. */
|
|
@@ -124,6 +139,7 @@ function DataTableOwned<Row extends RowData>(props: DataTableViewProps<Row> & Us
|
|
|
124
139
|
onRowClick,
|
|
125
140
|
activeRowId,
|
|
126
141
|
rowClassName,
|
|
142
|
+
ref,
|
|
127
143
|
...options
|
|
128
144
|
} = props;
|
|
129
145
|
const instance = useDataTable<Row>({
|
|
@@ -154,6 +170,7 @@ function DataTableOwned<Row extends RowData>(props: DataTableViewProps<Row> & Us
|
|
|
154
170
|
onRowClick={onRowClick}
|
|
155
171
|
activeRowId={activeRowId}
|
|
156
172
|
rowClassName={rowClassName}
|
|
173
|
+
ref={ref}
|
|
157
174
|
/>
|
|
158
175
|
);
|
|
159
176
|
}
|
|
@@ -275,6 +292,7 @@ function DataTableView<Row extends RowData>({
|
|
|
275
292
|
onRowClick,
|
|
276
293
|
activeRowId,
|
|
277
294
|
rowClassName,
|
|
295
|
+
ref,
|
|
278
296
|
}: DataTableViewProps<Row> & { instance: DataTableInstance<Row> }) {
|
|
279
297
|
const { table, paginationMode } = instance;
|
|
280
298
|
const rows = table.getRowModel().rows;
|
|
@@ -291,6 +309,23 @@ function DataTableView<Row extends RowData>({
|
|
|
291
309
|
const scrollRef = React.useRef<HTMLDivElement>(null);
|
|
292
310
|
const tableRef = React.useRef<HTMLTableElement>(null);
|
|
293
311
|
|
|
312
|
+
const refresh = instance.refetch;
|
|
313
|
+
React.useImperativeHandle(ref, () => ({ refresh, instance }), [refresh, instance]);
|
|
314
|
+
|
|
315
|
+
// Mandatory on every server-backed table (P-39): a Refresh button beside the
|
|
316
|
+
// search box and a stamp saying which moment these rows are. The switch only
|
|
317
|
+
// appears where the table asked for `autoRefresh`.
|
|
318
|
+
const refreshProps =
|
|
319
|
+
instance.dataSource.kind === "server"
|
|
320
|
+
? {
|
|
321
|
+
onRefresh: refresh,
|
|
322
|
+
isFetching: instance.isFetching,
|
|
323
|
+
lastUpdatedAt: instance.lastUpdatedAt,
|
|
324
|
+
lastErrorAt: instance.lastErrorAt,
|
|
325
|
+
autoRefresh: instance.autoRefresh,
|
|
326
|
+
}
|
|
327
|
+
: undefined;
|
|
328
|
+
|
|
294
329
|
/* Virtualisation ------------------------------------------------------ */
|
|
295
330
|
|
|
296
331
|
const virtualizer = useVirtualizer<HTMLDivElement, HTMLTableRowElement>({
|
|
@@ -587,6 +622,7 @@ function DataTableView<Row extends RowData>({
|
|
|
587
622
|
searchPlaceholder={searchPlaceholder}
|
|
588
623
|
enableExport={enableExport}
|
|
589
624
|
exportFileName={exportFileName}
|
|
625
|
+
refresh={refreshProps}
|
|
590
626
|
>
|
|
591
627
|
{toolbarActions}
|
|
592
628
|
</DataTableToolbar>
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
export { DataTable, DataTableView } from "./data-table";
|
|
4
|
-
export type { DataTableProps, DataTableViewProps } from "./data-table";
|
|
4
|
+
export type { DataTableHandle, DataTableProps, DataTableViewProps } from "./data-table";
|
|
5
5
|
export {
|
|
6
6
|
DataTableColumnHeader,
|
|
7
7
|
DataTableExportMenu,
|
|
8
8
|
DataTableFacetedFilter,
|
|
9
9
|
DataTablePagination,
|
|
10
|
+
DataTableRefresh,
|
|
10
11
|
DataTableToolbar,
|
|
11
12
|
DataTableViewOptions,
|
|
12
13
|
} from "./parts";
|
|
@@ -16,6 +17,7 @@ export type {
|
|
|
16
17
|
DataTableFacetedFilterOption,
|
|
17
18
|
DataTableFacetedFilterProps,
|
|
18
19
|
DataTablePaginationProps,
|
|
20
|
+
DataTableRefreshProps,
|
|
19
21
|
DataTableToolbarProps,
|
|
20
22
|
DataTableViewOptionsProps,
|
|
21
23
|
} from "./parts";
|
|
@@ -27,6 +29,8 @@ export type { ExportCell, ExportSheet } from "./export";
|
|
|
27
29
|
export type {
|
|
28
30
|
ClientSource,
|
|
29
31
|
DataSource,
|
|
32
|
+
DataTableAutoRefresh,
|
|
33
|
+
DataTableAutoRefreshState,
|
|
30
34
|
DataTableCell,
|
|
31
35
|
DataTableColumn,
|
|
32
36
|
DataTableColumnDef,
|
|
@@ -35,6 +39,7 @@ export type {
|
|
|
35
39
|
DataTableEditingCell,
|
|
36
40
|
DataTableInstance,
|
|
37
41
|
DataTablePaginationMode,
|
|
42
|
+
DataTableRefreshPause,
|
|
38
43
|
DataTableRow,
|
|
39
44
|
DataTableState,
|
|
40
45
|
DataTableTable,
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
EyeOff,
|
|
15
15
|
ListFilter,
|
|
16
16
|
PinOff,
|
|
17
|
+
RefreshCw,
|
|
17
18
|
Search,
|
|
18
19
|
Settings2,
|
|
19
20
|
X,
|
|
@@ -32,9 +33,11 @@ import {
|
|
|
32
33
|
} from "@/components/dropdown-menu";
|
|
33
34
|
import { Input } from "@/components/input";
|
|
34
35
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/select";
|
|
36
|
+
import { StatusDot } from "@/components/status-dot";
|
|
37
|
+
import { Switch } from "@/components/switch";
|
|
35
38
|
import { cn } from "@/lib/cn";
|
|
36
39
|
import { columnLabel, downloadCsv, downloadExcel } from "./export";
|
|
37
|
-
import type { DataTableColumn, DataTableTable } from "./types";
|
|
40
|
+
import type { DataTableAutoRefreshState, DataTableColumn, DataTableTable } from "./types";
|
|
38
41
|
|
|
39
42
|
/* ------------------------------------------------------------------------ */
|
|
40
43
|
/* Column header */
|
|
@@ -404,6 +407,11 @@ export interface DataTableToolbarProps<Row extends RowData>
|
|
|
404
407
|
/** @default true */
|
|
405
408
|
enableExport?: boolean;
|
|
406
409
|
exportFileName?: string;
|
|
410
|
+
/**
|
|
411
|
+
* Freshness controls beside the search box. `DataTable` fills this in for
|
|
412
|
+
* every server source; a hand-built toolbar must pass it too (P-39).
|
|
413
|
+
*/
|
|
414
|
+
refresh?: DataTableRefreshProps;
|
|
407
415
|
}
|
|
408
416
|
|
|
409
417
|
/**
|
|
@@ -418,6 +426,7 @@ function DataTableToolbar<Row extends RowData>({
|
|
|
418
426
|
children,
|
|
419
427
|
enableExport = true,
|
|
420
428
|
exportFileName,
|
|
429
|
+
refresh,
|
|
421
430
|
className,
|
|
422
431
|
...props
|
|
423
432
|
}: DataTableToolbarProps<Row>) {
|
|
@@ -435,54 +444,59 @@ function DataTableToolbar<Row extends RowData>({
|
|
|
435
444
|
className={cn("flex flex-wrap items-center gap-2", className)}
|
|
436
445
|
{...props}
|
|
437
446
|
>
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
<
|
|
441
|
-
<span className="
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
</Button>
|
|
448
|
-
</div>
|
|
449
|
-
) : (
|
|
450
|
-
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
451
|
-
{canSearch ? (
|
|
452
|
-
<div className="relative w-full max-w-64">
|
|
453
|
-
<Search
|
|
454
|
-
aria-hidden
|
|
455
|
-
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
|
|
456
|
-
/>
|
|
457
|
-
<Input
|
|
458
|
-
data-slot="data-table-search"
|
|
459
|
-
type="search"
|
|
460
|
-
aria-label="Search"
|
|
461
|
-
placeholder={searchPlaceholder}
|
|
462
|
-
value={globalFilter}
|
|
463
|
-
onChange={(e) => table.setGlobalFilter(e.target.value)}
|
|
464
|
-
className="h-8 pl-8 text-[length:var(--ds-text-caption-lg)]"
|
|
465
|
-
/>
|
|
466
|
-
</div>
|
|
467
|
-
) : null}
|
|
468
|
-
{facetedColumns.map((column) => (
|
|
469
|
-
<DataTableFacetedFilter key={column.id} column={column} />
|
|
470
|
-
))}
|
|
471
|
-
{isFiltered ? (
|
|
472
|
-
<Button
|
|
473
|
-
variant="ghost"
|
|
474
|
-
size="sm"
|
|
475
|
-
onClick={() => {
|
|
476
|
-
table.resetColumnFilters();
|
|
477
|
-
table.setGlobalFilter("");
|
|
478
|
-
}}
|
|
479
|
-
>
|
|
480
|
-
Reset
|
|
447
|
+
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
|
448
|
+
{selectedRows.length ? (
|
|
449
|
+
<div data-slot="data-table-bulk-actions" className="flex min-w-0 flex-wrap items-center gap-2">
|
|
450
|
+
<span className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]">
|
|
451
|
+
<span className="font-medium text-[color:var(--ds-foreground)]">{selectedRows.length}</span> selected
|
|
452
|
+
</span>
|
|
453
|
+
{bulkActions?.({ rows: selectedRows.map((r) => r.original), table })}
|
|
454
|
+
<Button variant="ghost" size="sm" onClick={() => table.resetRowSelection()}>
|
|
455
|
+
Clear
|
|
481
456
|
<X />
|
|
482
457
|
</Button>
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
458
|
+
</div>
|
|
459
|
+
) : (
|
|
460
|
+
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
461
|
+
{canSearch ? (
|
|
462
|
+
<div className="relative w-64 max-w-full">
|
|
463
|
+
<Search
|
|
464
|
+
aria-hidden
|
|
465
|
+
className="pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
|
|
466
|
+
/>
|
|
467
|
+
<Input
|
|
468
|
+
data-slot="data-table-search"
|
|
469
|
+
type="search"
|
|
470
|
+
aria-label="Search"
|
|
471
|
+
placeholder={searchPlaceholder}
|
|
472
|
+
value={globalFilter}
|
|
473
|
+
onChange={(e) => table.setGlobalFilter(e.target.value)}
|
|
474
|
+
className="h-8 pl-8 text-[length:var(--ds-text-caption-lg)]"
|
|
475
|
+
/>
|
|
476
|
+
</div>
|
|
477
|
+
) : null}
|
|
478
|
+
{facetedColumns.map((column) => (
|
|
479
|
+
<DataTableFacetedFilter key={column.id} column={column} />
|
|
480
|
+
))}
|
|
481
|
+
{isFiltered ? (
|
|
482
|
+
<Button
|
|
483
|
+
variant="ghost"
|
|
484
|
+
size="sm"
|
|
485
|
+
onClick={() => {
|
|
486
|
+
table.resetColumnFilters();
|
|
487
|
+
table.setGlobalFilter("");
|
|
488
|
+
}}
|
|
489
|
+
>
|
|
490
|
+
Reset
|
|
491
|
+
<X />
|
|
492
|
+
</Button>
|
|
493
|
+
) : null}
|
|
494
|
+
</div>
|
|
495
|
+
)}
|
|
496
|
+
{/* Beside the search box, and still there while a selection is up: the
|
|
497
|
+
stamp answers "how old is this?", which a selection does not change. */}
|
|
498
|
+
{refresh ? <DataTableRefresh {...refresh} /> : null}
|
|
499
|
+
</div>
|
|
486
500
|
<div className="flex items-center gap-2">
|
|
487
501
|
{children}
|
|
488
502
|
<DataTableViewOptions table={table} />
|
|
@@ -492,6 +506,167 @@ function DataTableToolbar<Row extends RowData>({
|
|
|
492
506
|
);
|
|
493
507
|
}
|
|
494
508
|
|
|
509
|
+
/* ------------------------------------------------------------------------ */
|
|
510
|
+
/* Refresh + freshness stamp */
|
|
511
|
+
/* ------------------------------------------------------------------------ */
|
|
512
|
+
|
|
513
|
+
/** A failed background refresh says so beside the stamp, then gets out of the way. */
|
|
514
|
+
const ERROR_VISIBLE_MS = 10_000;
|
|
515
|
+
/** Under a minute the stamp is relative; past it, the clock time it was taken. */
|
|
516
|
+
const RELATIVE_WINDOW_MS = 60_000;
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* Re-renders once, at a moment named in advance.
|
|
520
|
+
*
|
|
521
|
+
* The stamp has exactly two of them — the minute at which "just now" stops
|
|
522
|
+
* being true, and the ten seconds a failure is worth mentioning — and neither
|
|
523
|
+
* is a good reason to tick on a timer: a clock time, once shown, never changes
|
|
524
|
+
* again, so a periodic re-render would be one every fifteen seconds forever to
|
|
525
|
+
* paint the same eight characters.
|
|
526
|
+
*/
|
|
527
|
+
function useDeadline(force: () => void, at: number | null, after: number): void {
|
|
528
|
+
React.useEffect(() => {
|
|
529
|
+
if (at === null) return;
|
|
530
|
+
const left = after - (Date.now() - at);
|
|
531
|
+
if (left <= 0) return;
|
|
532
|
+
const id = setTimeout(force, left);
|
|
533
|
+
return () => clearTimeout(id);
|
|
534
|
+
}, [force, at, after]);
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function formatClock(at: number): string {
|
|
538
|
+
return new Date(at).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function pauseNote(pausedBy: DataTableAutoRefreshState["pausedBy"]): string | null {
|
|
542
|
+
switch (pausedBy) {
|
|
543
|
+
case "hidden":
|
|
544
|
+
return "Paused while this tab is in the background";
|
|
545
|
+
case "editing":
|
|
546
|
+
return "Paused while a cell is being edited";
|
|
547
|
+
case "selection":
|
|
548
|
+
return "Paused while rows are selected";
|
|
549
|
+
case "mutation":
|
|
550
|
+
return "Paused while a change is saving";
|
|
551
|
+
case "app":
|
|
552
|
+
return "Paused";
|
|
553
|
+
default:
|
|
554
|
+
return null;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
interface StampProps {
|
|
559
|
+
lastUpdatedAt: number | null;
|
|
560
|
+
lastErrorAt: number | null;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* "Updated 14:32", or "Updated just now" for the first minute. A server table
|
|
565
|
+
* is a photograph of a moment, and this is the only thing on screen that says
|
|
566
|
+
* which moment. Its own component so a re-render at the minute boundary costs
|
|
567
|
+
* eight words rather than the whole toolbar.
|
|
568
|
+
*/
|
|
569
|
+
function DataTableUpdatedStamp({ lastUpdatedAt, lastErrorAt }: StampProps) {
|
|
570
|
+
const [, force] = React.useReducer((n: number) => n + 1, 0);
|
|
571
|
+
useDeadline(force, lastUpdatedAt, RELATIVE_WINDOW_MS);
|
|
572
|
+
useDeadline(force, lastErrorAt, ERROR_VISIBLE_MS);
|
|
573
|
+
const now = Date.now();
|
|
574
|
+
|
|
575
|
+
if (lastErrorAt !== null && now - lastErrorAt < ERROR_VISIBLE_MS) {
|
|
576
|
+
return (
|
|
577
|
+
<span
|
|
578
|
+
data-slot="data-table-updated"
|
|
579
|
+
data-failed=""
|
|
580
|
+
className="whitespace-nowrap text-[length:var(--ds-text-caption)] text-[color:var(--ds-destructive)]"
|
|
581
|
+
>
|
|
582
|
+
Update failed
|
|
583
|
+
</span>
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
if (lastUpdatedAt === null) return null;
|
|
587
|
+
return (
|
|
588
|
+
<span
|
|
589
|
+
data-slot="data-table-updated"
|
|
590
|
+
title={new Date(lastUpdatedAt).toLocaleString()}
|
|
591
|
+
className="whitespace-nowrap text-[length:var(--ds-text-caption)] text-[color:var(--ds-muted-foreground)]"
|
|
592
|
+
>
|
|
593
|
+
{now - lastUpdatedAt < RELATIVE_WINDOW_MS
|
|
594
|
+
? "Updated just now"
|
|
595
|
+
: `Updated ${formatClock(lastUpdatedAt)}`}
|
|
596
|
+
</span>
|
|
597
|
+
);
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
export interface DataTableRefreshProps extends Omit<React.ComponentProps<"div">, "children"> {
|
|
601
|
+
/** Refetch the page on screen. The same query the interval drives. */
|
|
602
|
+
onRefresh: () => void;
|
|
603
|
+
/** Spins the icon and holds the button while a request is in flight. */
|
|
604
|
+
isFetching?: boolean;
|
|
605
|
+
/** `Date.now()` of the last successful response. */
|
|
606
|
+
lastUpdatedAt?: number | null;
|
|
607
|
+
/** `Date.now()` of the last failure; shown for ten seconds, then dropped. */
|
|
608
|
+
lastErrorAt?: number | null;
|
|
609
|
+
/** Renders the on/off switch and the live indicator. Omit for a table without `autoRefresh`. */
|
|
610
|
+
autoRefresh?: DataTableAutoRefreshState | null;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The freshness controls: Refresh, the last-updated stamp, and — only where a
|
|
615
|
+
* table opted in — the auto-refresh switch with its live indicator. Mandatory
|
|
616
|
+
* on every server-backed table (P-39); `DataTable` renders it beside the
|
|
617
|
+
* search box on its own.
|
|
618
|
+
*/
|
|
619
|
+
function DataTableRefresh({
|
|
620
|
+
onRefresh,
|
|
621
|
+
isFetching,
|
|
622
|
+
lastUpdatedAt = null,
|
|
623
|
+
lastErrorAt = null,
|
|
624
|
+
autoRefresh,
|
|
625
|
+
className,
|
|
626
|
+
...props
|
|
627
|
+
}: DataTableRefreshProps) {
|
|
628
|
+
const note = autoRefresh ? pauseNote(autoRefresh.pausedBy) : null;
|
|
629
|
+
return (
|
|
630
|
+
<div
|
|
631
|
+
data-slot="data-table-refresh"
|
|
632
|
+
className={cn("flex items-center gap-2", className)}
|
|
633
|
+
{...props}
|
|
634
|
+
>
|
|
635
|
+
<Button
|
|
636
|
+
variant="outline"
|
|
637
|
+
size="icon-sm"
|
|
638
|
+
aria-label="Refresh"
|
|
639
|
+
onClick={onRefresh}
|
|
640
|
+
disabled={isFetching}
|
|
641
|
+
>
|
|
642
|
+
<RefreshCw className={cn(isFetching && "animate-spin")} />
|
|
643
|
+
</Button>
|
|
644
|
+
<DataTableUpdatedStamp lastUpdatedAt={lastUpdatedAt} lastErrorAt={lastErrorAt} />
|
|
645
|
+
{autoRefresh ? (
|
|
646
|
+
<span data-slot="data-table-auto-refresh" className="flex items-center gap-1.5">
|
|
647
|
+
<Switch
|
|
648
|
+
aria-label="Auto refresh"
|
|
649
|
+
checked={autoRefresh.enabled}
|
|
650
|
+
onCheckedChange={autoRefresh.setEnabled}
|
|
651
|
+
data-backing-off={autoRefresh.backingOff ? "" : undefined}
|
|
652
|
+
/>
|
|
653
|
+
{autoRefresh.active ? (
|
|
654
|
+
<StatusDot data-slot="data-table-live" tone="success" live label="Live" />
|
|
655
|
+
) : (
|
|
656
|
+
<span
|
|
657
|
+
data-slot="data-table-auto-label"
|
|
658
|
+
title={note ?? undefined}
|
|
659
|
+
className="whitespace-nowrap text-[length:var(--ds-text-caption)] text-[color:var(--ds-muted-foreground)]"
|
|
660
|
+
>
|
|
661
|
+
{autoRefresh.enabled ? (note ? "Paused" : "Auto") : "Auto"}
|
|
662
|
+
</span>
|
|
663
|
+
)}
|
|
664
|
+
</span>
|
|
665
|
+
) : null}
|
|
666
|
+
</div>
|
|
667
|
+
);
|
|
668
|
+
}
|
|
669
|
+
|
|
495
670
|
/* ------------------------------------------------------------------------ */
|
|
496
671
|
/* Pagination */
|
|
497
672
|
/* ------------------------------------------------------------------------ */
|
|
@@ -578,6 +753,7 @@ export {
|
|
|
578
753
|
DataTableExportMenu,
|
|
579
754
|
DataTableFacetedFilter,
|
|
580
755
|
DataTablePagination,
|
|
756
|
+
DataTableRefresh,
|
|
581
757
|
DataTableToolbar,
|
|
582
758
|
DataTableViewOptions,
|
|
583
759
|
};
|