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.
@@ -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
- export function ModelTable<T extends { id: RowId }>({
225
- model, endpoint, columns, filters = [], presets = [], eyebrow, title,
226
- searchable = true, searchPlaceholder = 'Search…', onRowClick, addLabel, onAdd, defaultSort, defaultParams, fixedParams,
227
- selectable = false, batchActions = [],
228
- columnChooser = false, persistState = false, persistKey,
229
- autoRefresh = 0,
230
- rowExpand, rowExpandMultiple = false,
231
- groupBy, groupHeaderLabel, groupHeaderStyle = 'banner',
232
- exportFormats = [], exporter, rowTone,
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 URL doesn't carry one (default '-created'). */
269
+ /** Initial sort when the params store doesn't carry one (default '-created'). */
250
270
  defaultSort?: string;
251
271
  /**
252
- * Initial URL-backed query state. URL keys win; persisted state wins over
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; default derives from route + endpoint. */
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
- // fixedParams === null means "scope pending" — hold the query so no
359
- // unscoped request is ever fired or cached. undefined = unscoped table.
360
- const query = useModelList<T>(resolvedEndpoint, listParams, {
361
- enabled: hydrated && fixedParams !== null,
362
- sanitizeRow: model?.sanitizeRow,
363
- });
364
- const rows = useMemo(() => query.data?.rows ?? [], [query.data]);
365
- const count = query.data?.count ?? 0;
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 qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
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 qc.invalidateQueries({ queryKey: [resolvedEndpoint] });
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
- onClick={onRowClick ? () => onRowClick(row) : undefined}
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.isPending || query.isPlaceholderData || !hydrated ? (
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.isError ? (
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" />