portal-mojo 0.2.11 → 0.2.13
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/docs/ModelTable.md +84 -3
- package/docs/account.md +10 -0
- package/docs/charts.md +49 -0
- package/docs/params.md +24 -6
- package/package.json +1 -1
- package/src/account/AccountSections.tsx +22 -12
- package/src/account/actor.ts +40 -0
- package/src/account/api.ts +44 -17
- package/src/account/dialogs.tsx +25 -18
- package/src/account/models.ts +13 -6
- package/src/charts/SeriesChart.tsx +207 -57
- package/src/client/auth.ts +7 -4
- package/src/client/params.ts +21 -5
- package/src/styles/core.css +2 -0
- package/src/ui/ModelTable.tsx +120 -40
package/src/ui/ModelTable.tsx
CHANGED
|
@@ -17,9 +17,9 @@
|
|
|
17
17
|
// · groupBy — synthetic header rows interleaved on key change; falsy key
|
|
18
18
|
// = ungrouped tail (see ui/grouping.ts helpers)
|
|
19
19
|
// · exportFormats — server-side export via download_format (csv/json)
|
|
20
|
-
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
|
|
20
|
+
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode, type ComponentType, type Dispatch, type SetStateAction } from 'react';
|
|
21
21
|
import { useQueryClient } from '@tanstack/react-query';
|
|
22
|
-
import { PAGE_SIZES, useTableParams, readTableState, writeTableState, clearTableState, type PersistedTableState } from '../client/params';
|
|
22
|
+
import { PAGE_SIZES, useTableParams, readTableState, writeTableState, clearTableState, type PersistedTableState, type TableParamsMode } from '../client/params';
|
|
23
23
|
import { useModelList } from '../client/hooks';
|
|
24
24
|
import { mojoDownload } from '../client/client';
|
|
25
25
|
import type { ModelDef } from '../client/model';
|
|
@@ -100,6 +100,12 @@ function defaultCell(value: unknown, key: string): string {
|
|
|
100
100
|
return String(value);
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/** Nested controls retain their own action; plain cells activate the row. */
|
|
104
|
+
function isRowControl(target: EventTarget, row: HTMLTableRowElement): boolean {
|
|
105
|
+
return target instanceof Element && target !== row
|
|
106
|
+
&& target.closest('button, a, input, select, textarea, summary, [role="button"], [role="link"], [contenteditable="true"]') !== null;
|
|
107
|
+
}
|
|
108
|
+
|
|
103
109
|
/** Page-number window: {1, total, current±1} with … for the gaps. */
|
|
104
110
|
function pageWindow(current: number, total: number): (number | '…')[] {
|
|
105
111
|
const keep = new Set<number>([1, total]);
|
|
@@ -221,16 +227,30 @@ function MenuDropdown({ button, children, align = 'end' }: {
|
|
|
221
227
|
*/
|
|
222
228
|
export type RowId = number | string;
|
|
223
229
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
230
|
+
/** Controlled query result. An omitted data value clears previously rendered rows. */
|
|
231
|
+
export interface ModelTableQuery<T> {
|
|
232
|
+
data?: { rows: T[]; count: number };
|
|
233
|
+
status: 'pending' | 'success' | 'error';
|
|
234
|
+
isFetching: boolean;
|
|
235
|
+
isPlaceholderData?: boolean;
|
|
236
|
+
error?: unknown;
|
|
237
|
+
refetch: () => unknown;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** A stable component that owns fetching/caching and renders the shared table. */
|
|
241
|
+
export interface ModelTableQueryAdapterProps<T> {
|
|
242
|
+
/** Model-normalized table state, with fixedParams applied last. */
|
|
243
|
+
params: Params;
|
|
244
|
+
/** False until persistence is hydrated or while fixedParams is null. */
|
|
245
|
+
enabled: boolean;
|
|
246
|
+
children: (query: ModelTableQuery<T>) => ReactNode;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface ModelTableProps<T extends { id: RowId }> {
|
|
250
|
+
/** Replaces the default query entirely. Define the component outside render. */
|
|
251
|
+
queryAdapter?: ComponentType<ModelTableQueryAdapterProps<T>>;
|
|
252
|
+
/** Default 'url'. 'local' starts fresh per mount, without reading/writing route params. */
|
|
253
|
+
paramsMode?: TableParamsMode;
|
|
234
254
|
/** A defineModel definition — supplies the endpoint (and, later, schema). */
|
|
235
255
|
model?: ModelDef<T>;
|
|
236
256
|
/** Bare endpoint, for tables without a model definition. */
|
|
@@ -246,10 +266,10 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
246
266
|
onRowClick?: (row: T) => void;
|
|
247
267
|
addLabel?: string;
|
|
248
268
|
onAdd?: () => void;
|
|
249
|
-
/** Initial sort when the
|
|
269
|
+
/** Initial sort when the params store doesn't carry one (default '-created'). */
|
|
250
270
|
defaultSort?: string;
|
|
251
271
|
/**
|
|
252
|
-
* Initial
|
|
272
|
+
* Initial query state. URL keys win in URL mode; persisted state wins over
|
|
253
273
|
* these defaults. `defaultSort` remains as the backwards-compatible
|
|
254
274
|
* shorthand and wins over `defaultParams.sort` when both are supplied.
|
|
255
275
|
*/
|
|
@@ -269,7 +289,7 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
269
289
|
batchActions?: BatchAction<T>[];
|
|
270
290
|
columnChooser?: boolean;
|
|
271
291
|
persistState?: boolean;
|
|
272
|
-
/** Storage identity;
|
|
292
|
+
/** Storage identity; defaults to route + endpoint, or local::endpoint in local mode. */
|
|
273
293
|
persistKey?: string;
|
|
274
294
|
/** Interval refetch in SECONDS (5s floor). 0/absent = off. */
|
|
275
295
|
autoRefresh?: number;
|
|
@@ -286,15 +306,12 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
286
306
|
exporter?: (format: 'csv' | 'json', normalizedParams: Params) => Promise<void>;
|
|
287
307
|
/** Semantic queue emphasis without coupling callers to table CSS. */
|
|
288
308
|
rowTone?: (row: T) => RowTone | null;
|
|
289
|
-
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function ModelTable<T extends { id: RowId }>(props: ModelTableProps<T>) {
|
|
312
|
+
const { model, endpoint, columns, defaultSort, defaultParams, fixedParams, searchable = true, persistState = false, persistKey, queryAdapter: Adapter, paramsMode = 'url' } = props;
|
|
290
313
|
const resolvedEndpoint = model?.endpoint ?? endpoint;
|
|
291
314
|
if (!resolvedEndpoint) throw new Error('ModelTable: pass `model` or `endpoint`');
|
|
292
|
-
if (!GROUP_HEADER_STYLES.includes(groupHeaderStyle)) {
|
|
293
|
-
console.warn(`ModelTable: unknown groupHeaderStyle "${groupHeaderStyle}" — falling back to 'banner'`);
|
|
294
|
-
groupHeaderStyle = 'banner';
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
const qc = useQueryClient();
|
|
298
315
|
const paramDefaults = useMemo(() => ({
|
|
299
316
|
...(defaultParams?.search != null ? { search: String(defaultParams.search) } : {}),
|
|
300
317
|
...(defaultParams?.size != null ? { size: Number(defaultParams.size) } : {}),
|
|
@@ -319,10 +336,10 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
319
336
|
warnOnce(`ModelTable: "${key}" is in both fixedParams and defaultParams — the defaultParams copy is dead (fixed wins on the wire); remove it`);
|
|
320
337
|
}
|
|
321
338
|
}
|
|
322
|
-
const p = useTableParams(paramDefaults, defaultFilters, fixedKeys);
|
|
339
|
+
const p = useTableParams(paramDefaults, defaultFilters, fixedKeys, paramsMode);
|
|
323
340
|
|
|
324
341
|
// ── View persistence (WM-035): restore once BEFORE the first fetch ──
|
|
325
|
-
const storageKey = persistKey ?? `${(typeof location !== 'undefined' ? location.hash.split('?')[0] : '') || '#/'}::${resolvedEndpoint}
|
|
342
|
+
const storageKey = persistKey ?? (paramsMode === 'local' ? `local::${resolvedEndpoint}` : `${(typeof location !== 'undefined' ? location.hash.split('?')[0] : '') || '#/'}::${resolvedEndpoint}`);
|
|
326
343
|
const [hiddenCols, setHiddenCols] = useState<Set<string>>(() => new Set());
|
|
327
344
|
const [hydrated, setHydrated] = useState(!persistState);
|
|
328
345
|
const restoreRan = useRef(false);
|
|
@@ -355,14 +372,57 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
355
372
|
model?.normalizeListParams ? model.normalizeListParams(tableParams) : tableParams,
|
|
356
373
|
fixedParams,
|
|
357
374
|
);
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
375
|
+
const enabled = hydrated && fixedParams !== null;
|
|
376
|
+
const render = (query: ModelTableQuery<T>) => (
|
|
377
|
+
<ModelTableView {...props} query={query} context={{ p, hydrated, hiddenCols, setHiddenCols, storageKey, listParams, fixedSignature, resolvedEndpoint, enabled }} />
|
|
378
|
+
);
|
|
379
|
+
return Adapter
|
|
380
|
+
? <Adapter params={listParams} enabled={enabled}>{render}</Adapter>
|
|
381
|
+
: <DefaultTableQuery endpoint={resolvedEndpoint} params={listParams} enabled={enabled} sanitizeRow={model?.sanitizeRow}>{render}</DefaultTableQuery>;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function DefaultTableQuery<T>({ endpoint, params, enabled, sanitizeRow, children }: ModelTableQueryAdapterProps<T> & {
|
|
385
|
+
endpoint: string;
|
|
386
|
+
sanitizeRow?: (row: T) => T;
|
|
387
|
+
}) {
|
|
388
|
+
const query = useModelList<T>(endpoint, params, { enabled, sanitizeRow });
|
|
389
|
+
return children(query);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
interface ModelTableContext {
|
|
393
|
+
p: ReturnType<typeof useTableParams>;
|
|
394
|
+
hydrated: boolean;
|
|
395
|
+
hiddenCols: Set<string>;
|
|
396
|
+
setHiddenCols: Dispatch<SetStateAction<Set<string>>>;
|
|
397
|
+
storageKey: string;
|
|
398
|
+
listParams: Params;
|
|
399
|
+
fixedSignature: string;
|
|
400
|
+
resolvedEndpoint: string;
|
|
401
|
+
enabled: boolean;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function ModelTableView<T extends { id: RowId }>(props: ModelTableProps<T> & {
|
|
405
|
+
query: ModelTableQuery<T>;
|
|
406
|
+
context: ModelTableContext;
|
|
407
|
+
}) {
|
|
408
|
+
const {
|
|
409
|
+
columns, filters = [], presets = [], eyebrow, title,
|
|
410
|
+
searchable = true, searchPlaceholder = 'Search…', onRowClick, addLabel, onAdd,
|
|
411
|
+
selectable = false, batchActions = [],
|
|
412
|
+
columnChooser = false, persistState = false,
|
|
413
|
+
autoRefresh = 0,
|
|
414
|
+
rowExpand, rowExpandMultiple = false,
|
|
415
|
+
groupBy, groupHeaderLabel, groupHeaderStyle: requestedGroupHeaderStyle = 'banner',
|
|
416
|
+
exportFormats = [], exporter, rowTone,
|
|
417
|
+
} = props;
|
|
418
|
+
const { query, context: { p, hydrated, hiddenCols, setHiddenCols, storageKey, listParams, fixedSignature, resolvedEndpoint, enabled } } = props;
|
|
419
|
+
const qc = useQueryClient();
|
|
420
|
+
const groupHeaderStyle = GROUP_HEADER_STYLES.includes(requestedGroupHeaderStyle) ? requestedGroupHeaderStyle : 'banner';
|
|
421
|
+
if (groupHeaderStyle !== requestedGroupHeaderStyle) {
|
|
422
|
+
warnOnce(`ModelTable: unknown groupHeaderStyle "${requestedGroupHeaderStyle}" — falling back to 'banner'`);
|
|
423
|
+
}
|
|
424
|
+
const rows = useMemo(() => enabled ? query.data?.rows ?? [] : [], [query.data, enabled]);
|
|
425
|
+
const count = enabled ? query.data?.count ?? 0 : 0;
|
|
366
426
|
const totalPages = Math.max(1, Math.ceil(count / p.size));
|
|
367
427
|
const firstRow = count === 0 ? 0 : (p.page - 1) * p.size + 1;
|
|
368
428
|
const lastRow = Math.min(count, (p.page - 1) * p.size + rows.length);
|
|
@@ -479,7 +539,8 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
479
539
|
toast.error(err instanceof Error ? err.message : `${action.label} failed`);
|
|
480
540
|
}
|
|
481
541
|
setSelected(new Set());
|
|
482
|
-
void
|
|
542
|
+
if (props.queryAdapter) void query.refetch();
|
|
543
|
+
else void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
|
|
483
544
|
return;
|
|
484
545
|
}
|
|
485
546
|
const results = await Promise.allSettled(targets.map((row) => action.run!(row, prepared)));
|
|
@@ -491,7 +552,8 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
491
552
|
// Always clear + refetch on a non-cancel path so the user sees the
|
|
492
553
|
// partial state after failures.
|
|
493
554
|
setSelected(new Set());
|
|
494
|
-
void
|
|
555
|
+
if (props.queryAdapter) void query.refetch();
|
|
556
|
+
else void qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
|
|
495
557
|
};
|
|
496
558
|
|
|
497
559
|
// ── Row expand ───────────────────────────────────────────────────────
|
|
@@ -526,7 +588,7 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
526
588
|
refetchRef.current = query.refetch;
|
|
527
589
|
const selectedRef = useRef(selected);
|
|
528
590
|
selectedRef.current = selected;
|
|
529
|
-
const autoRefreshMs = autoRefresh > 0 ? Math.max(5, autoRefresh) * 1000 : 0;
|
|
591
|
+
const autoRefreshMs = enabled && autoRefresh > 0 ? Math.max(5, autoRefresh) * 1000 : 0;
|
|
530
592
|
useEffect(() => {
|
|
531
593
|
if (!autoRefreshMs) return;
|
|
532
594
|
const skip = () =>
|
|
@@ -610,7 +672,17 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
610
672
|
isExpanded ? 'row-expanded' : '',
|
|
611
673
|
tone ? `row-tone-${tone}` : '',
|
|
612
674
|
].filter(Boolean).join(' ') || undefined}
|
|
613
|
-
|
|
675
|
+
tabIndex={onRowClick ? 0 : undefined}
|
|
676
|
+
onClick={onRowClick ? (event) => {
|
|
677
|
+
if (event.defaultPrevented || isRowControl(event.target, event.currentTarget)) return;
|
|
678
|
+
onRowClick(row);
|
|
679
|
+
} : undefined}
|
|
680
|
+
onKeyDown={onRowClick ? (event) => {
|
|
681
|
+
if (event.target !== event.currentTarget || event.defaultPrevented || event.repeat) return;
|
|
682
|
+
if (event.key !== 'Enter' && event.key !== ' ') return;
|
|
683
|
+
event.preventDefault();
|
|
684
|
+
onRowClick(row);
|
|
685
|
+
} : undefined}
|
|
614
686
|
>
|
|
615
687
|
{expandEnabled && (
|
|
616
688
|
<td className="col-expand" onClick={(e) => e.stopPropagation()}>
|
|
@@ -710,9 +782,9 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
710
782
|
)}
|
|
711
783
|
</MenuDropdown>
|
|
712
784
|
)}
|
|
713
|
-
{exportFormats.length > 0 && (
|
|
785
|
+
{exportFormats.length > 0 && (!props.queryAdapter || exporter) && (
|
|
714
786
|
<MenuDropdown button={(open, toggle) => (
|
|
715
|
-
<button className="btn btn-compact" onClick={toggle} aria-expanded={open} title="Export">
|
|
787
|
+
<button className="btn btn-compact" disabled={!enabled} onClick={toggle} aria-expanded={open} title="Export">
|
|
716
788
|
<i className="bi bi-download" /> <span className="btn-label">Export</span>
|
|
717
789
|
</button>
|
|
718
790
|
)}>
|
|
@@ -729,6 +801,7 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
729
801
|
<button
|
|
730
802
|
className="btn-icon"
|
|
731
803
|
title={autoRefreshMs > 0 ? `Refresh · auto every ${Math.round(autoRefreshMs / 1000)}s` : 'Refresh'}
|
|
804
|
+
disabled={!enabled}
|
|
732
805
|
onClick={() => query.refetch()}
|
|
733
806
|
>
|
|
734
807
|
<i className={`bi bi-arrow-repeat${query.isFetching ? ' spin' : ''}`} />
|
|
@@ -786,6 +859,13 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
786
859
|
</div>
|
|
787
860
|
)}
|
|
788
861
|
|
|
862
|
+
{props.queryAdapter && enabled && query.status === 'error' && query.data && (
|
|
863
|
+
<div className="form-alert" role="alert">
|
|
864
|
+
{query.error instanceof Error ? query.error.message : 'Could not refresh records'}{' '}
|
|
865
|
+
<button className="btn btn-compact" onClick={() => query.refetch()}>Retry</button>
|
|
866
|
+
</div>
|
|
867
|
+
)}
|
|
868
|
+
|
|
789
869
|
<div className="tbl-scroll">
|
|
790
870
|
<table className="tbl">
|
|
791
871
|
<thead>
|
|
@@ -820,7 +900,7 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
820
900
|
change still serving the previous key's rows
|
|
821
901
|
(isPlaceholderData). Background refetches of the
|
|
822
902
|
SAME key (autoRefresh) keep the live rows. */}
|
|
823
|
-
{query.
|
|
903
|
+
{query.status === 'pending' || query.isPlaceholderData || !enabled ? (
|
|
824
904
|
Array.from({ length: Math.min(p.size, 8) }).map((_, i) => (
|
|
825
905
|
<tr key={i} className="skel-row" aria-hidden="true">
|
|
826
906
|
{expandEnabled && <td className="col-expand" />}
|
|
@@ -842,7 +922,7 @@ export function ModelTable<T extends { id: RowId }>({
|
|
|
842
922
|
))}
|
|
843
923
|
</tr>
|
|
844
924
|
))
|
|
845
|
-
) : query.
|
|
925
|
+
) : query.status === 'error' && (!props.queryAdapter || !query.data) ? (
|
|
846
926
|
<tr><td colSpan={totalCols}>
|
|
847
927
|
<div className="empty-state">
|
|
848
928
|
<i className="bi bi-exclamation-triangle" />
|