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
|
@@ -109,6 +109,52 @@ export type DataTableTable<Row extends RowData> = ReactTable<DataTableFeatures<R
|
|
|
109
109
|
|
|
110
110
|
export type DataTablePaginationMode = "pages" | "infinite" | "none";
|
|
111
111
|
|
|
112
|
+
/* ---------------------------------------------------------------------- */
|
|
113
|
+
/* Freshness (DESIGN-D19 / P-39) */
|
|
114
|
+
/* ---------------------------------------------------------------------- */
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Opt-in polling for a server source, for tables that are *watched* rather
|
|
118
|
+
* than edited — runs in progress, alert lists, a board left open on a wall
|
|
119
|
+
* screen. It renders an on/off switch beside the Refresh button, off until
|
|
120
|
+
* the user turns it on, and it is never a substitute for push: a table that
|
|
121
|
+
* subscribes to a notification stream should call `refresh()` from the stream
|
|
122
|
+
* and leave the interval as the fallback for when the stream is unavailable.
|
|
123
|
+
*/
|
|
124
|
+
export interface DataTableAutoRefresh {
|
|
125
|
+
/**
|
|
126
|
+
* Poll interval while the switch is on. Clamped to a 30 s floor; anything
|
|
127
|
+
* shorter is a load problem wearing a freshness costume. @default 60000
|
|
128
|
+
*/
|
|
129
|
+
intervalMs?: number;
|
|
130
|
+
/**
|
|
131
|
+
* An extra pause the app owns — a pending mutation, a wizard mid-flight.
|
|
132
|
+
* The tab being hidden, an open inline editor and a live selection are
|
|
133
|
+
* already handled by the component.
|
|
134
|
+
*/
|
|
135
|
+
paused?: boolean;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Why the interval is not running even though the switch is on. */
|
|
139
|
+
export type DataTableRefreshPause = "hidden" | "editing" | "selection" | "mutation" | "app";
|
|
140
|
+
|
|
141
|
+
/** The auto-refresh half of the toolbar's freshness controls. */
|
|
142
|
+
export interface DataTableAutoRefreshState {
|
|
143
|
+
/** The user's switch, remembered per `stateKey`. False until they turn it on. */
|
|
144
|
+
enabled: boolean;
|
|
145
|
+
setEnabled: (on: boolean) => void;
|
|
146
|
+
/** `enabled` and nothing is pausing it: the interval is really running. */
|
|
147
|
+
active: boolean;
|
|
148
|
+
/** Set while `enabled` but paused, so the UI can say why. */
|
|
149
|
+
pausedBy: DataTableRefreshPause | null;
|
|
150
|
+
/** The interval in force, including error back-off. */
|
|
151
|
+
intervalMs: number;
|
|
152
|
+
/** The configured interval after the 30 s clamp. */
|
|
153
|
+
baseIntervalMs: number;
|
|
154
|
+
/** True while consecutive failures have stretched the interval. */
|
|
155
|
+
backingOff: boolean;
|
|
156
|
+
}
|
|
157
|
+
|
|
112
158
|
export interface UseDataTableOptions<Row extends RowData> {
|
|
113
159
|
columns: ReadonlyArray<DataTableColumnDef<Row, any>>;
|
|
114
160
|
dataSource: DataSource<Row>;
|
|
@@ -147,6 +193,19 @@ export interface UseDataTableOptions<Row extends RowData> {
|
|
|
147
193
|
onRowSelectionChange?: (rows: Row[]) => void;
|
|
148
194
|
/** Debounce applied to the search box before it reaches a server source. @default 250 */
|
|
149
195
|
searchDebounceMs?: number;
|
|
196
|
+
/**
|
|
197
|
+
* Turns on the auto-refresh switch beside the Refresh button (server
|
|
198
|
+
* sources only). Off by default; `stateKey` is required with it, because
|
|
199
|
+
* the user's choice is remembered per table.
|
|
200
|
+
*/
|
|
201
|
+
autoRefresh?: DataTableAutoRefresh;
|
|
202
|
+
/**
|
|
203
|
+
* Identity of this table for anything remembered between visits. Required
|
|
204
|
+
* when `autoRefresh` is set: the choice is stored under
|
|
205
|
+
* `cortena.data-table.auto-refresh.<stateKey>`, so turning polling on for
|
|
206
|
+
* the runs list does not turn it on for every other table.
|
|
207
|
+
*/
|
|
208
|
+
stateKey?: string;
|
|
150
209
|
}
|
|
151
210
|
|
|
152
211
|
export interface DataTableEditingCell {
|
|
@@ -173,6 +232,15 @@ export interface DataTableInstance<Row extends RowData> {
|
|
|
173
232
|
hasNextPage: boolean;
|
|
174
233
|
/** Search text the server last received (debounced), for consumers rendering their own toolbar. */
|
|
175
234
|
searchQuery: string;
|
|
235
|
+
/**
|
|
236
|
+
* `Date.now()` of the last successful server response, for the "Updated
|
|
237
|
+
* hh:mm" stamp. Null before the first one, and for a client source.
|
|
238
|
+
*/
|
|
239
|
+
lastUpdatedAt: number | null;
|
|
240
|
+
/** `Date.now()` of the last failed one; the toolbar shows it briefly. */
|
|
241
|
+
lastErrorAt: number | null;
|
|
242
|
+
/** Null unless `autoRefresh` was configured on a server source. */
|
|
243
|
+
autoRefresh: DataTableAutoRefreshState | null;
|
|
176
244
|
editing: {
|
|
177
245
|
cell: DataTableEditingCell | null;
|
|
178
246
|
start: (cell: DataTableEditingCell) => void;
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type ColumnFiltersState,
|
|
6
6
|
type PaginationState,
|
|
7
7
|
type RowData,
|
|
8
|
+
type RowSelectionState,
|
|
8
9
|
type SortingState,
|
|
9
10
|
} from "@tanstack/react-table";
|
|
10
11
|
import * as React from "react";
|
|
@@ -12,9 +13,11 @@ import { createColumnHelper, dataTableFeatures } from "./features";
|
|
|
12
13
|
import { expandColumn, selectColumn } from "./system-columns";
|
|
13
14
|
import type {
|
|
14
15
|
DataSource,
|
|
16
|
+
DataTableAutoRefreshState,
|
|
15
17
|
DataTableColumnDef,
|
|
16
18
|
DataTableEditingCell,
|
|
17
19
|
DataTableInstance,
|
|
20
|
+
DataTableRefreshPause,
|
|
18
21
|
DataTableRow,
|
|
19
22
|
DataTableTable,
|
|
20
23
|
TableQuery,
|
|
@@ -24,6 +27,66 @@ import { useServerSource } from "./use-server-source";
|
|
|
24
27
|
|
|
25
28
|
const EMPTY_ROWS: never[] = [];
|
|
26
29
|
|
|
30
|
+
/** The floor on `autoRefresh.intervalMs`; anything shorter is load, not freshness. */
|
|
31
|
+
export const AUTO_REFRESH_MIN_MS = 30_000;
|
|
32
|
+
/** Used when `autoRefresh` names no interval. */
|
|
33
|
+
export const AUTO_REFRESH_DEFAULT_MS = 60_000;
|
|
34
|
+
/** `cortena.data-table.auto-refresh.<stateKey>` holds `"on"` or `"off"`. */
|
|
35
|
+
export const AUTO_REFRESH_STORAGE_PREFIX = "cortena.data-table.auto-refresh.";
|
|
36
|
+
|
|
37
|
+
/** One warning per process, not one per render. */
|
|
38
|
+
const warned = { missingStateKey: false };
|
|
39
|
+
|
|
40
|
+
function readStoredFlag(key: string): boolean {
|
|
41
|
+
try {
|
|
42
|
+
return window.localStorage.getItem(key) === "on";
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The user's auto-refresh choice, remembered per table.
|
|
50
|
+
*
|
|
51
|
+
* It starts off and is read from storage in an effect rather than in the
|
|
52
|
+
* initial state, so a server-rendered table and its first client render agree
|
|
53
|
+
* — a switch that hydrates in the opposite position is a React mismatch and,
|
|
54
|
+
* worse, a table that starts polling before the page is interactive.
|
|
55
|
+
*/
|
|
56
|
+
function usePersistedFlag(storageKey: string | null): [boolean, (on: boolean) => void] {
|
|
57
|
+
const [on, setOn] = React.useState(false);
|
|
58
|
+
React.useEffect(() => {
|
|
59
|
+
setOn(storageKey ? readStoredFlag(storageKey) : false);
|
|
60
|
+
}, [storageKey]);
|
|
61
|
+
const set = React.useCallback(
|
|
62
|
+
(next: boolean) => {
|
|
63
|
+
setOn(next);
|
|
64
|
+
if (!storageKey) return;
|
|
65
|
+
try {
|
|
66
|
+
window.localStorage.setItem(storageKey, next ? "on" : "off");
|
|
67
|
+
} catch {
|
|
68
|
+
/* Private mode, or storage full: the switch still works for this visit. */
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
[storageKey],
|
|
72
|
+
);
|
|
73
|
+
return [on, set];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const subscribeVisibility = (onChange: () => void) => {
|
|
77
|
+
document.addEventListener("visibilitychange", onChange);
|
|
78
|
+
return () => document.removeEventListener("visibilitychange", onChange);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
/** True while the tab is in the background. Polling a tab nobody is looking at is a battery bill. */
|
|
82
|
+
function useDocumentHidden(): boolean {
|
|
83
|
+
return React.useSyncExternalStore(
|
|
84
|
+
subscribeVisibility,
|
|
85
|
+
() => document.visibilityState === "hidden",
|
|
86
|
+
() => false,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
27
90
|
function useDebounced<T>(value: T, ms: number): T {
|
|
28
91
|
const [debounced, setDebounced] = React.useState(value);
|
|
29
92
|
React.useEffect(() => {
|
|
@@ -73,6 +136,8 @@ export function useDataTable<Row extends RowData>(
|
|
|
73
136
|
onCellCommit,
|
|
74
137
|
onRowSelectionChange,
|
|
75
138
|
searchDebounceMs = 250,
|
|
139
|
+
autoRefresh,
|
|
140
|
+
stateKey,
|
|
76
141
|
} = options;
|
|
77
142
|
|
|
78
143
|
const isServer = dataSource.kind === "server";
|
|
@@ -89,6 +154,13 @@ export function useDataTable<Row extends RowData>(
|
|
|
89
154
|
const [pagination, setPagination] = React.useState<PaginationState>(
|
|
90
155
|
initialState?.pagination ?? { pageIndex: 0, pageSize },
|
|
91
156
|
);
|
|
157
|
+
// Selection and the open editor are lifted for the same reason the query
|
|
158
|
+
// slices are: auto-refresh has to know about them before the query for this
|
|
159
|
+
// render is declared, and `table.state` only exists after `useTable`.
|
|
160
|
+
const [rowSelection, setRowSelection] = React.useState<RowSelectionState>(
|
|
161
|
+
initialState?.rowSelection ?? {},
|
|
162
|
+
);
|
|
163
|
+
const [editingCell, setEditingCell] = React.useState<DataTableEditingCell | null>(null);
|
|
92
164
|
|
|
93
165
|
const searchQuery = useDebounced(globalFilter, isServer ? searchDebounceMs : 0);
|
|
94
166
|
|
|
@@ -116,7 +188,44 @@ export function useDataTable<Row extends RowData>(
|
|
|
116
188
|
[searchQuery, columnFilters, sorting, pagination.pageIndex, pagination.pageSize],
|
|
117
189
|
);
|
|
118
190
|
|
|
119
|
-
|
|
191
|
+
/* Freshness (DESIGN-D19 / P-39) -------------------------------------- */
|
|
192
|
+
|
|
193
|
+
const wantsAuto = isServer && autoRefresh !== undefined;
|
|
194
|
+
React.useEffect(() => {
|
|
195
|
+
if (!wantsAuto || stateKey || warned.missingStateKey) return;
|
|
196
|
+
warned.missingStateKey = true;
|
|
197
|
+
console.warn(
|
|
198
|
+
"[cortena-ui] DataTable: `autoRefresh` needs a `stateKey`. Without one the " +
|
|
199
|
+
"user's on/off choice cannot be remembered between visits, and two tables " +
|
|
200
|
+
"on one screen would share the same setting.",
|
|
201
|
+
);
|
|
202
|
+
}, [wantsAuto, stateKey]);
|
|
203
|
+
const storageKey = wantsAuto && stateKey ? AUTO_REFRESH_STORAGE_PREFIX + stateKey : null;
|
|
204
|
+
const [autoEnabled, setAutoEnabled] = usePersistedFlag(storageKey);
|
|
205
|
+
const hidden = useDocumentHidden();
|
|
206
|
+
const baseIntervalMs = Math.max(
|
|
207
|
+
autoRefresh?.intervalMs ?? AUTO_REFRESH_DEFAULT_MS,
|
|
208
|
+
AUTO_REFRESH_MIN_MS,
|
|
209
|
+
);
|
|
210
|
+
// The polite rules, in the order a user would explain them. Each one is a
|
|
211
|
+
// reason the interval is not running, and the toolbar says which.
|
|
212
|
+
const pausedBy: DataTableRefreshPause | null = hidden
|
|
213
|
+
? "hidden"
|
|
214
|
+
: editingCell
|
|
215
|
+
? "editing"
|
|
216
|
+
: Object.keys(rowSelection).length > 0
|
|
217
|
+
? "selection"
|
|
218
|
+
: autoRefresh?.paused
|
|
219
|
+
? "app"
|
|
220
|
+
: null;
|
|
221
|
+
const autoActive = wantsAuto && autoEnabled && pausedBy === null;
|
|
222
|
+
|
|
223
|
+
const server = useServerSource<Row>(
|
|
224
|
+
isServer ? dataSource : undefined,
|
|
225
|
+
query,
|
|
226
|
+
paginationMode,
|
|
227
|
+
wantsAuto ? { intervalMs: baseIntervalMs, active: autoActive } : undefined,
|
|
228
|
+
);
|
|
120
229
|
|
|
121
230
|
const data: ReadonlyArray<Row> = isServer ? server.rows : dataSource.rows;
|
|
122
231
|
|
|
@@ -147,11 +256,12 @@ export function useDataTable<Row extends RowData>(
|
|
|
147
256
|
getRowId,
|
|
148
257
|
getSubRows,
|
|
149
258
|
initialState,
|
|
150
|
-
state: { globalFilter, columnFilters, sorting, pagination },
|
|
259
|
+
state: { globalFilter, columnFilters, sorting, pagination, rowSelection },
|
|
151
260
|
onGlobalFilterChange: setGlobalFilter,
|
|
152
261
|
onColumnFiltersChange: setColumnFilters,
|
|
153
262
|
onSortingChange: setSorting,
|
|
154
263
|
onPaginationChange: setPagination,
|
|
264
|
+
onRowSelectionChange: setRowSelection,
|
|
155
265
|
manualFiltering: isServer,
|
|
156
266
|
manualSorting: isServer,
|
|
157
267
|
manualPagination: isServer || paginationMode !== "pages",
|
|
@@ -173,7 +283,6 @@ export function useDataTable<Row extends RowData>(
|
|
|
173
283
|
});
|
|
174
284
|
|
|
175
285
|
// Selection callback with original rows, for consumers that keep selection outside.
|
|
176
|
-
const rowSelection = table.state.rowSelection;
|
|
177
286
|
const onSelectionRef = React.useRef(onRowSelectionChange);
|
|
178
287
|
onSelectionRef.current = onRowSelectionChange;
|
|
179
288
|
const firstSelection = React.useRef(true);
|
|
@@ -186,7 +295,6 @@ export function useDataTable<Row extends RowData>(
|
|
|
186
295
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
187
296
|
}, [rowSelection]);
|
|
188
297
|
|
|
189
|
-
const [editingCell, setEditingCell] = React.useState<DataTableEditingCell | null>(null);
|
|
190
298
|
const commit = React.useCallback(
|
|
191
299
|
async (row: DataTableRow<Row>, columnId: string, value: unknown) => {
|
|
192
300
|
const meta = table.getColumn(columnId)?.columnDef.meta;
|
|
@@ -205,6 +313,43 @@ export function useDataTable<Row extends RowData>(
|
|
|
205
313
|
[editingCell, commit],
|
|
206
314
|
);
|
|
207
315
|
|
|
316
|
+
// The stamp keeps the last real response time: with keepPreviousData a page
|
|
317
|
+
// change puts a placeholder on screen whose `dataUpdatedAt` is zero, and the
|
|
318
|
+
// stamp must not blink out while the next page loads.
|
|
319
|
+
const lastUpdatedRef = React.useRef<number | null>(null);
|
|
320
|
+
if (server.lastUpdatedAt && server.lastUpdatedAt > (lastUpdatedRef.current ?? 0)) {
|
|
321
|
+
lastUpdatedRef.current = server.lastUpdatedAt;
|
|
322
|
+
}
|
|
323
|
+
const lastUpdatedAt = lastUpdatedRef.current;
|
|
324
|
+
|
|
325
|
+
const autoState = React.useMemo<DataTableAutoRefreshState | null>(
|
|
326
|
+
() =>
|
|
327
|
+
wantsAuto
|
|
328
|
+
? {
|
|
329
|
+
enabled: autoEnabled,
|
|
330
|
+
setEnabled: setAutoEnabled,
|
|
331
|
+
active: autoActive && !server.mutating,
|
|
332
|
+
// A mutation in flight is the one pause only the query layer can
|
|
333
|
+
// see; it is folded in here so the toolbar can still say why.
|
|
334
|
+
pausedBy: autoEnabled ? (pausedBy ?? (server.mutating ? "mutation" : null)) : null,
|
|
335
|
+
intervalMs: server.intervalMs === false ? baseIntervalMs : server.intervalMs,
|
|
336
|
+
baseIntervalMs,
|
|
337
|
+
backingOff: server.failures > 0,
|
|
338
|
+
}
|
|
339
|
+
: null,
|
|
340
|
+
[
|
|
341
|
+
wantsAuto,
|
|
342
|
+
autoEnabled,
|
|
343
|
+
setAutoEnabled,
|
|
344
|
+
autoActive,
|
|
345
|
+
pausedBy,
|
|
346
|
+
server.mutating,
|
|
347
|
+
server.intervalMs,
|
|
348
|
+
server.failures,
|
|
349
|
+
baseIntervalMs,
|
|
350
|
+
],
|
|
351
|
+
);
|
|
352
|
+
|
|
208
353
|
const total = isServer
|
|
209
354
|
? (server.total ?? 0)
|
|
210
355
|
: paginationMode === "pages"
|
|
@@ -224,6 +369,9 @@ export function useDataTable<Row extends RowData>(
|
|
|
224
369
|
fetchNextPage: server.fetchNextPage,
|
|
225
370
|
hasNextPage: server.hasNextPage,
|
|
226
371
|
searchQuery,
|
|
372
|
+
lastUpdatedAt,
|
|
373
|
+
lastErrorAt: server.lastErrorAt,
|
|
374
|
+
autoRefresh: autoState,
|
|
227
375
|
editing,
|
|
228
376
|
};
|
|
229
377
|
}
|
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
keepPreviousData,
|
|
5
5
|
QueryClientContext,
|
|
6
6
|
useInfiniteQuery,
|
|
7
|
+
useIsMutating,
|
|
7
8
|
useQuery,
|
|
8
9
|
} from "@tanstack/react-query";
|
|
9
10
|
import * as React from "react";
|
|
@@ -18,6 +19,62 @@ export interface ServerSourceState<Row> {
|
|
|
18
19
|
refetch: () => void;
|
|
19
20
|
fetchNextPage: () => void;
|
|
20
21
|
hasNextPage: boolean;
|
|
22
|
+
/** `Date.now()` of the last successful response; null before the first. */
|
|
23
|
+
lastUpdatedAt: number | null;
|
|
24
|
+
/** `Date.now()` of the last failure; null before the first. */
|
|
25
|
+
lastErrorAt: number | null;
|
|
26
|
+
/** Consecutive failures. Doubles the interval; zero again after a success. */
|
|
27
|
+
failures: number;
|
|
28
|
+
/** The interval actually scheduled, false when nothing is polling. */
|
|
29
|
+
intervalMs: number | false;
|
|
30
|
+
/** A mutation is in flight on the query client, so polling is held. */
|
|
31
|
+
mutating: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What the interval needs from the caller; the pauses it cannot see are already applied. */
|
|
35
|
+
export interface ServerSourceAutoRefresh {
|
|
36
|
+
/** Base interval, already clamped to the 30 s floor. */
|
|
37
|
+
intervalMs: number;
|
|
38
|
+
/** The switch is on and nothing the table can see is pausing it. */
|
|
39
|
+
active: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Errors stretch the interval rather than hammering a failing endpoint. */
|
|
43
|
+
export const AUTO_REFRESH_MAX_MS = 300_000;
|
|
44
|
+
|
|
45
|
+
function backoffOf(base: number, failures: number): number {
|
|
46
|
+
return Math.min(base * 2 ** failures, AUTO_REFRESH_MAX_MS);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface RequestStamps {
|
|
50
|
+
ok: number | null;
|
|
51
|
+
err: number | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Consecutive failures, counted from the timestamps the source reports: one
|
|
56
|
+
* more for every new error, back to zero on any success.
|
|
57
|
+
*
|
|
58
|
+
* It is state, and it is read *before* the query is declared, so the interval
|
|
59
|
+
* handed to `refetchInterval` on the next render already carries the back-off.
|
|
60
|
+
* The timestamps only exist after the query, hence the ref: the effect runs
|
|
61
|
+
* after the commit, by which time the render has filled it in.
|
|
62
|
+
*/
|
|
63
|
+
function useFailureCount(stamps: React.RefObject<RequestStamps>): number {
|
|
64
|
+
const [failures, setFailures] = React.useState(0);
|
|
65
|
+
const seen = React.useRef<RequestStamps>({ ok: null, err: null });
|
|
66
|
+
React.useEffect(() => {
|
|
67
|
+
const { ok, err } = stamps.current;
|
|
68
|
+
if (err !== null && err !== seen.current.err) {
|
|
69
|
+
seen.current.err = err;
|
|
70
|
+
setFailures((n) => n + 1);
|
|
71
|
+
}
|
|
72
|
+
if (ok !== null && ok !== seen.current.ok) {
|
|
73
|
+
seen.current.ok = ok;
|
|
74
|
+
setFailures(0);
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return failures;
|
|
21
78
|
}
|
|
22
79
|
|
|
23
80
|
const EMPTY: never[] = [];
|
|
@@ -48,30 +105,43 @@ export function useServerSource<Row>(
|
|
|
48
105
|
source: ServerSource<Row> | undefined,
|
|
49
106
|
query: TableQuery,
|
|
50
107
|
mode: "pages" | "infinite" | "none",
|
|
108
|
+
auto?: ServerSourceAutoRefresh,
|
|
51
109
|
): ServerSourceState<Row> {
|
|
52
110
|
const client = React.useContext(QueryClientContext);
|
|
53
111
|
const [strategy] = React.useState<"query" | "local">(() => (client ? "query" : "local"));
|
|
54
112
|
// Hook order is stable for the life of the component because `strategy` is.
|
|
55
113
|
return strategy === "query"
|
|
56
|
-
? useQueryStrategy(source, query, mode)
|
|
57
|
-
: useLocalStrategy(source, query, mode);
|
|
114
|
+
? useQueryStrategy(source, query, mode, auto)
|
|
115
|
+
: useLocalStrategy(source, query, mode, auto);
|
|
58
116
|
}
|
|
59
117
|
|
|
60
118
|
function useQueryStrategy<Row>(
|
|
61
119
|
source: ServerSource<Row> | undefined,
|
|
62
120
|
query: TableQuery,
|
|
63
121
|
mode: "pages" | "infinite" | "none",
|
|
122
|
+
auto?: ServerSourceAutoRefresh,
|
|
64
123
|
): ServerSourceState<Row> {
|
|
65
124
|
const id = React.useId();
|
|
66
125
|
const base = source?.queryKey ?? ["cortena-data-table", id];
|
|
67
126
|
const infinite = mode === "infinite";
|
|
68
127
|
const fetch = source?.fetch;
|
|
69
128
|
|
|
129
|
+
// A mutation in flight is the fifth pause: refetching over a save the user
|
|
130
|
+
// has just fired shows them the row as it was a moment before it changed.
|
|
131
|
+
const mutating = useIsMutating() > 0;
|
|
132
|
+
// The interval and the Refresh button are the same query: `refetchInterval`
|
|
133
|
+
// and `refetch()` on one `useQuery`, never a second fetch path beside it.
|
|
134
|
+
const stamps = React.useRef<RequestStamps>({ ok: null, err: null });
|
|
135
|
+
const failures = useFailureCount(stamps);
|
|
136
|
+
const refetchInterval =
|
|
137
|
+
auto && auto.active && !mutating ? backoffOf(auto.intervalMs, failures) : (false as const);
|
|
138
|
+
|
|
70
139
|
const page = useQuery({
|
|
71
140
|
queryKey: [...base, "page", query],
|
|
72
141
|
queryFn: ({ signal }) => fetch!(query, signal),
|
|
73
142
|
enabled: Boolean(fetch) && !infinite,
|
|
74
143
|
placeholderData: keepPreviousData,
|
|
144
|
+
refetchInterval: infinite ? false : refetchInterval,
|
|
75
145
|
});
|
|
76
146
|
|
|
77
147
|
const scope = scopeOf(query);
|
|
@@ -85,8 +155,14 @@ function useQueryStrategy<Row>(
|
|
|
85
155
|
},
|
|
86
156
|
enabled: Boolean(fetch) && infinite,
|
|
87
157
|
placeholderData: keepPreviousData,
|
|
158
|
+
refetchInterval: infinite ? refetchInterval : false,
|
|
88
159
|
});
|
|
89
160
|
|
|
161
|
+
const live = infinite ? pages : page;
|
|
162
|
+
const lastUpdatedAt = live.dataUpdatedAt || null;
|
|
163
|
+
const lastErrorAt = live.errorUpdatedAt || null;
|
|
164
|
+
stamps.current = { ok: lastUpdatedAt, err: lastErrorAt };
|
|
165
|
+
|
|
90
166
|
const flatRows = React.useMemo(
|
|
91
167
|
() => (pages.data ? pages.data.pages.flatMap((p) => p.rows) : EMPTY),
|
|
92
168
|
[pages.data],
|
|
@@ -102,6 +178,11 @@ function useQueryStrategy<Row>(
|
|
|
102
178
|
refetch: () => {},
|
|
103
179
|
fetchNextPage: () => {},
|
|
104
180
|
hasNextPage: false,
|
|
181
|
+
lastUpdatedAt: null,
|
|
182
|
+
lastErrorAt: null,
|
|
183
|
+
failures: 0,
|
|
184
|
+
intervalMs: false,
|
|
185
|
+
mutating,
|
|
105
186
|
};
|
|
106
187
|
}
|
|
107
188
|
|
|
@@ -117,6 +198,11 @@ function useQueryStrategy<Row>(
|
|
|
117
198
|
if (pages.hasNextPage && !pages.isFetchingNextPage) void pages.fetchNextPage();
|
|
118
199
|
},
|
|
119
200
|
hasNextPage: pages.hasNextPage,
|
|
201
|
+
lastUpdatedAt,
|
|
202
|
+
lastErrorAt,
|
|
203
|
+
failures,
|
|
204
|
+
intervalMs: refetchInterval,
|
|
205
|
+
mutating,
|
|
120
206
|
};
|
|
121
207
|
}
|
|
122
208
|
|
|
@@ -129,6 +215,11 @@ function useQueryStrategy<Row>(
|
|
|
129
215
|
refetch: () => void page.refetch(),
|
|
130
216
|
fetchNextPage: () => {},
|
|
131
217
|
hasNextPage: false,
|
|
218
|
+
lastUpdatedAt,
|
|
219
|
+
lastErrorAt,
|
|
220
|
+
failures,
|
|
221
|
+
intervalMs: refetchInterval,
|
|
222
|
+
mutating,
|
|
132
223
|
};
|
|
133
224
|
}
|
|
134
225
|
|
|
@@ -136,14 +227,19 @@ interface LocalState<Row> {
|
|
|
136
227
|
pages: TableResult<Row>[];
|
|
137
228
|
scopeKey: string;
|
|
138
229
|
page: number;
|
|
230
|
+
/** The refresh generation this state answers; `tick` moving on means stale. */
|
|
231
|
+
tick: number;
|
|
139
232
|
isFetching: boolean;
|
|
140
233
|
error: Error | null;
|
|
234
|
+
updatedAt: number | null;
|
|
235
|
+
errorAt: number | null;
|
|
141
236
|
}
|
|
142
237
|
|
|
143
238
|
function useLocalStrategy<Row>(
|
|
144
239
|
source: ServerSource<Row> | undefined,
|
|
145
240
|
query: TableQuery,
|
|
146
241
|
mode: "pages" | "infinite" | "none",
|
|
242
|
+
auto?: ServerSourceAutoRefresh,
|
|
147
243
|
): ServerSourceState<Row> {
|
|
148
244
|
const infinite = mode === "infinite";
|
|
149
245
|
const fetch = source?.fetch;
|
|
@@ -157,15 +253,26 @@ function useLocalStrategy<Row>(
|
|
|
157
253
|
pages: [],
|
|
158
254
|
scopeKey,
|
|
159
255
|
page: -1,
|
|
256
|
+
tick: -1,
|
|
160
257
|
isFetching: false,
|
|
161
258
|
error: null,
|
|
259
|
+
updatedAt: null,
|
|
260
|
+
errorAt: null,
|
|
162
261
|
});
|
|
163
262
|
|
|
164
263
|
const loadedForScope = state.scopeKey === scopeKey ? state.pages : EMPTY;
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
264
|
+
// A refresh — the button or the interval — bumps `tick`; the rows already on
|
|
265
|
+
// screen stay there until the new ones arrive, exactly as keepPreviousData
|
|
266
|
+
// behaves under a provider. Nothing about sort, filters, page or selection
|
|
267
|
+
// is touched, because none of them live here.
|
|
268
|
+
const stale = state.tick !== tick;
|
|
269
|
+
const restart = stale || state.scopeKey !== scopeKey;
|
|
270
|
+
const targetPage = infinite ? (restart ? 0 : loadedForScope.length) : query.page;
|
|
271
|
+
const needs =
|
|
272
|
+
stale ||
|
|
273
|
+
(infinite
|
|
274
|
+
? loadedForScope.length < wantedPages
|
|
275
|
+
: !(state.scopeKey === scopeKey && state.page === query.page && state.pages.length > 0));
|
|
169
276
|
const requestKey = `${scopeKey}|${targetPage}|${tick}`;
|
|
170
277
|
|
|
171
278
|
// The consumer's `fetch` is usually an inline arrow — `fetch: q => api.list(q)`
|
|
@@ -188,16 +295,27 @@ function useLocalStrategy<Row>(
|
|
|
188
295
|
(result) => {
|
|
189
296
|
if (controller.signal.aborted) return;
|
|
190
297
|
setState((s) => ({
|
|
191
|
-
pages: infinite && s.scopeKey === scopeKey ? [...s.pages, result] : [result],
|
|
298
|
+
pages: infinite && !restart && s.scopeKey === scopeKey ? [...s.pages, result] : [result],
|
|
192
299
|
scopeKey,
|
|
193
300
|
page: targetPage,
|
|
301
|
+
tick,
|
|
194
302
|
isFetching: false,
|
|
195
303
|
error: null,
|
|
304
|
+
updatedAt: Date.now(),
|
|
305
|
+
errorAt: s.errorAt,
|
|
196
306
|
}));
|
|
197
307
|
},
|
|
198
308
|
(err: unknown) => {
|
|
199
309
|
if (controller.signal.aborted) return;
|
|
200
|
-
|
|
310
|
+
// `tick` is recorded on the failure too: without it the request stays
|
|
311
|
+
// "wanted" and a single dead endpoint is retried on every render.
|
|
312
|
+
setState((s) => ({
|
|
313
|
+
...s,
|
|
314
|
+
tick,
|
|
315
|
+
isFetching: false,
|
|
316
|
+
error: toError(err),
|
|
317
|
+
errorAt: Date.now(),
|
|
318
|
+
}));
|
|
201
319
|
},
|
|
202
320
|
);
|
|
203
321
|
return () => controller.abort();
|
|
@@ -206,6 +324,19 @@ function useLocalStrategy<Row>(
|
|
|
206
324
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
207
325
|
}, [hasFetch, requestKey, needs]);
|
|
208
326
|
|
|
327
|
+
const stamps = React.useRef<RequestStamps>({ ok: null, err: null });
|
|
328
|
+
stamps.current = { ok: state.updatedAt, err: state.errorAt };
|
|
329
|
+
const failures = useFailureCount(stamps);
|
|
330
|
+
const intervalMs = auto && auto.active ? backoffOf(auto.intervalMs, failures) : (false as const);
|
|
331
|
+
|
|
332
|
+
// Without a QueryClientProvider there is no `refetchInterval` to lean on, so
|
|
333
|
+
// the same `tick` the button bumps is bumped on a timer. Still one path.
|
|
334
|
+
React.useEffect(() => {
|
|
335
|
+
if (intervalMs === false) return;
|
|
336
|
+
const id = setInterval(() => setTick((t) => t + 1), intervalMs);
|
|
337
|
+
return () => clearInterval(id);
|
|
338
|
+
}, [intervalMs]);
|
|
339
|
+
|
|
209
340
|
// Like keepPreviousData: the last result stays on screen while the next loads.
|
|
210
341
|
const rows = React.useMemo(
|
|
211
342
|
() => (infinite ? state.pages.flatMap((p) => p.rows) : (state.pages[0]?.rows ?? EMPTY)),
|
|
@@ -224,6 +355,11 @@ function useLocalStrategy<Row>(
|
|
|
224
355
|
refetch: () => {},
|
|
225
356
|
fetchNextPage: () => {},
|
|
226
357
|
hasNextPage: false,
|
|
358
|
+
lastUpdatedAt: null,
|
|
359
|
+
lastErrorAt: null,
|
|
360
|
+
failures: 0,
|
|
361
|
+
intervalMs: false,
|
|
362
|
+
mutating: false,
|
|
227
363
|
};
|
|
228
364
|
}
|
|
229
365
|
|
|
@@ -233,13 +369,15 @@ function useLocalStrategy<Row>(
|
|
|
233
369
|
isLoading: state.pages.length === 0 && !state.error,
|
|
234
370
|
isFetching: state.isFetching,
|
|
235
371
|
error: state.error,
|
|
236
|
-
refetch: () =>
|
|
237
|
-
setState((s) => ({ ...s, pages: [], page: -1, error: null }));
|
|
238
|
-
setTick((t) => t + 1);
|
|
239
|
-
},
|
|
372
|
+
refetch: () => setTick((t) => t + 1),
|
|
240
373
|
fetchNextPage: () => {
|
|
241
374
|
if (hasNextPage && !state.isFetching) setWanted({ scopeKey, n: loadedForScope.length + 1 });
|
|
242
375
|
},
|
|
243
376
|
hasNextPage,
|
|
377
|
+
lastUpdatedAt: state.updatedAt,
|
|
378
|
+
lastErrorAt: state.errorAt,
|
|
379
|
+
failures,
|
|
380
|
+
intervalMs,
|
|
381
|
+
mutating: false,
|
|
244
382
|
};
|
|
245
383
|
}
|