@viliha/vui-ui 1.13.1 → 1.14.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/AGENT.md +18 -1
- package/CHANGELOG.md +36 -0
- package/package.json +1 -1
- package/src/record-view.tsx +182 -29
- package/template/app/(app)/data-table/page.tsx +28 -52
- package/template/lib/seo.ts +1 -0
package/AGENT.md
CHANGED
|
@@ -173,6 +173,18 @@ scaffolded files in the repo are two separate things.
|
|
|
173
173
|
`npx @viliha/vui-ui@latest init --dry-run`, then re-run with `--force` (commit
|
|
174
174
|
or stash your work first so you can review the diff) or copy in just the files
|
|
175
175
|
you want.
|
|
176
|
+
- **Keep-alive tab cache (1.13.1) lives in a scaffolded file**, so an upgrade
|
|
177
|
+
won't bring it. In `app/_components/open-tabs.tsx`, inside `KeepAliveTabs`,
|
|
178
|
+
make sure the cache is written **once per route**, not on every render:
|
|
179
|
+
```ts
|
|
180
|
+
// ✅ preserves the mounted page across tab switches (no remount, no refetch)
|
|
181
|
+
if (!cache.current.has(active)) cache.current.set(active, children);
|
|
182
|
+
// ❌ old: cache.current.set(active, children) // remounts on re-activation
|
|
183
|
+
```
|
|
184
|
+
Re-pull the file with `init --force`, or apply this one-liner by hand.
|
|
185
|
+
- **Server tables that shouldn't refetch on tab switch:** use RecordView's
|
|
186
|
+
`fetcher` + `cacheKey` + `persistKey` (all in the package, so they arrive
|
|
187
|
+
with the upgrade) — see the Filtering/Tables notes above.
|
|
176
188
|
5. **Re-check token overrides.** If you overrode any `theme.css` tokens, confirm
|
|
177
189
|
they still line up after the upgrade.
|
|
178
190
|
6. **Verify** before you commit: run type-check, lint, and build.
|
|
@@ -340,7 +352,12 @@ Form controls come from the field: `options` → `Select`, `input: "combobox"`
|
|
|
340
352
|
|
|
341
353
|
Loading from a server: pass `loading` to `RecordView` while the fetch is in flight — it shows shimmering skeleton rows (matched to the columns) instead of an empty-state flash; clear it when the data arrives. Don't build your own spinner overlay.
|
|
342
354
|
|
|
343
|
-
Server-side (large tables)
|
|
355
|
+
Server-side (large tables) — two ways:
|
|
356
|
+
|
|
357
|
+
1. **`fetcher` (preferred).** Pass `fetcher={(query, signal) => Promise<{ rows, total }>}` and RecordView owns the read path: it fetches on every query change, manages `data`/`rowCount`/`loading`, aborts superseded requests, and — with `cacheKey` — caches responses so returning to a tab is instant with no refetch. Add `persistKey` so page/sort/filters restore on remount and hit the cache. Optional `cache={{ max, ttlMs }}` and `onError`. Mutations are optimistic + invalidate + background refetch. Don't wire `data`/`onQueryChange`/`loading` yourself in this mode.
|
|
358
|
+
2. **`manual` + `onQueryChange` (lower-level).** RecordView reports the query; you fetch and set `data` + `rowCount` + `loading`, guarding out-of-order responses and caching yourself.
|
|
359
|
+
|
|
360
|
+
Either way, don't reimplement pagination/sort. Reference: the Data Table demo page (shadcn/ui section) uses `fetcher` + `cacheKey` + `persistKey` against a simulated backend.
|
|
344
361
|
|
|
345
362
|
Dependent/cascading options: make `options` (form) or `filterable.options` (filter) a function — `(draft) => …` in the form, `(values) => …` in the filter — and one field's choices depend on another; RecordView clears the child when the parent change invalidates it. Keep `options` a static array for the bulk "Set {label}" action. If a field has both a function `options` and `renderInput`, guard with `Array.isArray(field.options)` before mapping (renderInput doesn't get the draft to resolve it).
|
|
346
363
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,42 @@ backward-compatible features, **major** for breaking changes.
|
|
|
7
7
|
|
|
8
8
|
To upgrade, see [Upgrading](./AGENT.md#upgrading) in the agent guide.
|
|
9
9
|
|
|
10
|
+
## 1.14.0 — 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Built-in server data source for `RecordView`: `fetcher` + query cache.** Pass
|
|
15
|
+
`fetcher={(query, signal) => Promise<{ rows, total }>}` and RecordView owns the
|
|
16
|
+
whole read path — it fetches on every query change, manages `data` / `rowCount`
|
|
17
|
+
/ `loading`, aborts superseded requests via the `signal`, and (with `cacheKey`)
|
|
18
|
+
caches responses so returning to a tab is instant with no refetch. Combine with
|
|
19
|
+
`persistKey` to restore page/sort/filters on remount and hit the cache. New
|
|
20
|
+
props: `fetcher`, `cacheKey`, `cache` (`{ max, ttlMs }` LRU), `onError`.
|
|
21
|
+
Mutations are optimistic → invalidate → background refetch. This replaces the
|
|
22
|
+
hand-wired `data`/`onQueryChange`/`loading` + `Map` boilerplate (still supported
|
|
23
|
+
as the lower-level API). The Data Table demo now uses it.
|
|
24
|
+
- **Changelog on the docs site** at `/docs/changelog`, rendered straight from this
|
|
25
|
+
file at build time (single source, never drifts). Linked in the docs nav.
|
|
26
|
+
|
|
27
|
+
**For agents:** for a server-backed table, prefer `fetcher` + `cacheKey` +
|
|
28
|
+
`persistKey` over wiring `onQueryChange`/`data`/`loading` + a cache yourself.
|
|
29
|
+
Docs: `/docs/data-table` (Server-side data → "Let RecordView own the fetch").
|
|
30
|
+
|
|
31
|
+
## 1.13.2 — 2026-07-26
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **Data Table demo no longer reloads on tab switch.** Keep-alive keeps a page
|
|
36
|
+
mounted, but under the App Router an async (server-fetching) page can still
|
|
37
|
+
remount on tab switch and re-run its fetch. The demo now caches fetched pages
|
|
38
|
+
in a module-scoped `Map` keyed by the query (served synchronously, no shimmer)
|
|
39
|
+
and passes `persistKey`, so returning to the tab restores the exact page/sort/
|
|
40
|
+
filters and shows the data instantly — no round-trip.
|
|
41
|
+
|
|
42
|
+
**For agents:** for a server-backed table, cache responses (module `Map` or
|
|
43
|
+
your data layer) and use `persistKey`; don't rely on keep-alive alone to avoid
|
|
44
|
+
refetches. Docs: `/docs/data-table` (Persisting across tab switches).
|
|
45
|
+
|
|
10
46
|
## 1.13.1 — 2026-07-26
|
|
11
47
|
|
|
12
48
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.14.0",
|
|
4
4
|
"description": "Vui UI — a clean, token-driven React admin/CRM component library built on Tailwind CSS v4, shadcn-style patterns, and Radix Icons. Ships as TypeScript source (Just-in-Time), compiled by the consuming app.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Suman Bonakurthi",
|
package/src/record-view.tsx
CHANGED
|
@@ -352,6 +352,48 @@ function resolveOptions<T>(
|
|
|
352
352
|
return typeof opts === "function" ? opts(draft) : (opts ?? []);
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
+
// Module-scoped response cache for RecordView's `fetcher` mode. Namespaced by
|
|
356
|
+
// `cacheKey` and living outside any component, so a cached page survives a
|
|
357
|
+
// remount / tab switch — the return is instant with no refetch. LRU per
|
|
358
|
+
// namespace (insertion order = recency), optional TTL.
|
|
359
|
+
type RvCacheEntry = { rows: unknown[]; total: number; at: number };
|
|
360
|
+
const RV_CACHE = new Map<string, Map<string, RvCacheEntry>>();
|
|
361
|
+
|
|
362
|
+
function rvQueryKey<T>(q: ServerQuery<T>): string {
|
|
363
|
+
return JSON.stringify([q.page, q.pageSize, q.sort, q.search, q.filters]);
|
|
364
|
+
}
|
|
365
|
+
function rvCacheGet(
|
|
366
|
+
ns: string,
|
|
367
|
+
key: string,
|
|
368
|
+
ttlMs: number,
|
|
369
|
+
): RvCacheEntry | null {
|
|
370
|
+
const bucket = RV_CACHE.get(ns);
|
|
371
|
+
const hit = bucket?.get(key);
|
|
372
|
+
if (!hit) return null;
|
|
373
|
+
if (ttlMs > 0 && Date.now() - hit.at > ttlMs) {
|
|
374
|
+
bucket!.delete(key);
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
// Refresh recency.
|
|
378
|
+
bucket!.delete(key);
|
|
379
|
+
bucket!.set(key, hit);
|
|
380
|
+
return hit;
|
|
381
|
+
}
|
|
382
|
+
function rvCacheSet(ns: string, key: string, entry: RvCacheEntry, max: number) {
|
|
383
|
+
let bucket = RV_CACHE.get(ns);
|
|
384
|
+
if (!bucket) {
|
|
385
|
+
bucket = new Map();
|
|
386
|
+
RV_CACHE.set(ns, bucket);
|
|
387
|
+
}
|
|
388
|
+
bucket.delete(key);
|
|
389
|
+
bucket.set(key, entry);
|
|
390
|
+
while (bucket.size > max) {
|
|
391
|
+
const oldest = bucket.keys().next().value;
|
|
392
|
+
if (oldest === undefined) break;
|
|
393
|
+
bucket.delete(oldest);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
355
397
|
interface RecordViewProps<T extends { id: RowId }> {
|
|
356
398
|
title: string;
|
|
357
399
|
singular: string;
|
|
@@ -413,6 +455,25 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
413
455
|
* update `data` + `rowCount` + `loading` in response. Fires once on mount for
|
|
414
456
|
* the initial load; debounce inside if keyword changes are chatty. */
|
|
415
457
|
onQueryChange?: (query: ServerQuery<T>) => void;
|
|
458
|
+
/** Server data source. Providing it turns on `manual` and hands RecordView
|
|
459
|
+
* ownership of the read path: it calls this on every query change and manages
|
|
460
|
+
* `data` / `rowCount` / `loading` + caching itself — so you don't wire those
|
|
461
|
+
* or `onQueryChange`. Return the current page plus the server total. The
|
|
462
|
+
* `signal` aborts superseded requests. Mutually exclusive with the
|
|
463
|
+
* consumer-managed props above (if both are set, `fetcher` wins). */
|
|
464
|
+
fetcher?: (
|
|
465
|
+
query: ServerQuery<T>,
|
|
466
|
+
signal: AbortSignal,
|
|
467
|
+
) => Promise<{ rows: T[]; total: number }>;
|
|
468
|
+
/** Namespaces the `fetcher` response cache (like `persistKey`). Responses are
|
|
469
|
+
* cached per query and survive remounts / tab switches, so returning to a tab
|
|
470
|
+
* is instant with no refetch. Omit → no caching (always refetch). */
|
|
471
|
+
cacheKey?: string;
|
|
472
|
+
/** `fetcher` cache tuning. Default `{ max: 50, ttlMs: 0 }` (LRU, no expiry). */
|
|
473
|
+
cache?: { max?: number; ttlMs?: number };
|
|
474
|
+
/** Called when a `fetcher` request rejects (non-abort). RecordView keeps the
|
|
475
|
+
* previously loaded data and clears the loading state. */
|
|
476
|
+
onError?: (error: unknown, query: ServerQuery<T>) => void;
|
|
416
477
|
}
|
|
417
478
|
|
|
418
479
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -439,16 +500,89 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
439
500
|
manual = false,
|
|
440
501
|
rowCount,
|
|
441
502
|
onQueryChange,
|
|
503
|
+
fetcher,
|
|
504
|
+
cacheKey,
|
|
505
|
+
cache,
|
|
506
|
+
onError,
|
|
442
507
|
}: RecordViewProps<T>) {
|
|
443
508
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
444
509
|
// Surface the page title/icon in the app's global top bar.
|
|
445
510
|
usePageTitle(title, TitleIcon);
|
|
446
|
-
// Rows
|
|
511
|
+
// Rows: `fetcher`-owned (server), controlled (data + onDataChange), or held
|
|
512
|
+
// internally. `fetcher` implies manual mode.
|
|
513
|
+
const fetching = fetcher !== undefined;
|
|
514
|
+
const isManual = manual || fetching;
|
|
447
515
|
const [internalRows, setInternalRows] = React.useState<T[]>(initialData);
|
|
448
516
|
const controlled = data !== undefined;
|
|
449
|
-
|
|
517
|
+
|
|
518
|
+
// Fetcher-managed state (only used when `fetcher` is set).
|
|
519
|
+
const [fetchedData, setFetchedData] = React.useState<T[]>([]);
|
|
520
|
+
const [fetchedTotal, setFetchedTotal] = React.useState(0);
|
|
521
|
+
const [fetchedLoading, setFetchedLoading] = React.useState(fetching);
|
|
522
|
+
const reqIdRef = React.useRef(0);
|
|
523
|
+
const abortRef = React.useRef<AbortController | null>(null);
|
|
524
|
+
const queryRef = React.useRef<ServerQuery<T> | null>(null);
|
|
525
|
+
const ttlMs = cache?.ttlMs ?? 0;
|
|
526
|
+
const cacheMax = cache?.max ?? 50;
|
|
527
|
+
|
|
528
|
+
const runFetch = React.useCallback(
|
|
529
|
+
(q: ServerQuery<T>, opts?: { background?: boolean }) => {
|
|
530
|
+
if (!fetcher) return;
|
|
531
|
+
// Foreground: serve from cache if present (instant, no shimmer).
|
|
532
|
+
if (!opts?.background && cacheKey) {
|
|
533
|
+
const hit = rvCacheGet(cacheKey, rvQueryKey(q), ttlMs);
|
|
534
|
+
if (hit) {
|
|
535
|
+
setFetchedData(hit.rows as T[]);
|
|
536
|
+
setFetchedTotal(hit.total);
|
|
537
|
+
setFetchedLoading(false);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const id = ++reqIdRef.current;
|
|
542
|
+
abortRef.current?.abort();
|
|
543
|
+
const controller = new AbortController();
|
|
544
|
+
abortRef.current = controller;
|
|
545
|
+
if (!opts?.background) setFetchedLoading(true);
|
|
546
|
+
fetcher(q, controller.signal)
|
|
547
|
+
.then((res) => {
|
|
548
|
+
if (id !== reqIdRef.current) return; // superseded
|
|
549
|
+
if (cacheKey)
|
|
550
|
+
rvCacheSet(
|
|
551
|
+
cacheKey,
|
|
552
|
+
rvQueryKey(q),
|
|
553
|
+
{ rows: res.rows, total: res.total, at: Date.now() },
|
|
554
|
+
cacheMax,
|
|
555
|
+
);
|
|
556
|
+
setFetchedData(res.rows);
|
|
557
|
+
setFetchedTotal(res.total);
|
|
558
|
+
setFetchedLoading(false);
|
|
559
|
+
})
|
|
560
|
+
.catch((err) => {
|
|
561
|
+
if (controller.signal.aborted || id !== reqIdRef.current) return;
|
|
562
|
+
setFetchedLoading(false);
|
|
563
|
+
onError?.(err, q);
|
|
564
|
+
});
|
|
565
|
+
},
|
|
566
|
+
[fetcher, cacheKey, ttlMs, cacheMax, onError],
|
|
567
|
+
);
|
|
568
|
+
// Abort any in-flight request on unmount.
|
|
569
|
+
React.useEffect(() => () => abortRef.current?.abort(), []);
|
|
570
|
+
|
|
571
|
+
const rows = fetching ? fetchedData : controlled ? data : internalRows;
|
|
450
572
|
const setRows = React.useCallback(
|
|
451
573
|
(updater: React.SetStateAction<T[]>) => {
|
|
574
|
+
if (fetching) {
|
|
575
|
+
// Optimistic local update, then invalidate the cache and refetch the
|
|
576
|
+
// current query in the background so the table reflects server truth.
|
|
577
|
+
setFetchedData((prev) =>
|
|
578
|
+
typeof updater === "function"
|
|
579
|
+
? (updater as (p: T[]) => T[])(prev)
|
|
580
|
+
: updater,
|
|
581
|
+
);
|
|
582
|
+
if (cacheKey) RV_CACHE.delete(cacheKey);
|
|
583
|
+
if (queryRef.current) runFetch(queryRef.current, { background: true });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
452
586
|
if (controlled) {
|
|
453
587
|
const next =
|
|
454
588
|
typeof updater === "function"
|
|
@@ -459,7 +593,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
459
593
|
setInternalRows(updater);
|
|
460
594
|
}
|
|
461
595
|
},
|
|
462
|
-
[controlled, data, onDataChange],
|
|
596
|
+
[fetching, cacheKey, runFetch, controlled, data, onDataChange],
|
|
463
597
|
);
|
|
464
598
|
const [filter, setFilter] = usePersistentState(
|
|
465
599
|
persistKey ? `${persistKey}::filter` : undefined,
|
|
@@ -588,7 +722,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
588
722
|
const processed = React.useMemo(() => {
|
|
589
723
|
// Server mode: `rows` is already the filtered/sorted current page — render
|
|
590
724
|
// it verbatim (no client-side filter/sort).
|
|
591
|
-
if (
|
|
725
|
+
if (isManual) return rows;
|
|
592
726
|
let out = rows;
|
|
593
727
|
const q = filter.trim().toLowerCase();
|
|
594
728
|
if (q) {
|
|
@@ -617,7 +751,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
617
751
|
});
|
|
618
752
|
}
|
|
619
753
|
return out;
|
|
620
|
-
}, [
|
|
754
|
+
}, [isManual, rows, filter, sort, fields, getPrimary]);
|
|
621
755
|
|
|
622
756
|
const activeRow = rows.find((r) => r.id === activeId) ?? null;
|
|
623
757
|
const deleteTarget =
|
|
@@ -628,18 +762,32 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
628
762
|
// Pagination (derived; `page` is clamped so it never points past the last page).
|
|
629
763
|
// Server mode: totals come from `rowCount`, and `data` is already this page —
|
|
630
764
|
// so render it whole (no slice) and size the range to what the server returned.
|
|
631
|
-
const total =
|
|
765
|
+
const total = isManual
|
|
766
|
+
? fetching
|
|
767
|
+
? fetchedTotal
|
|
768
|
+
: (rowCount ?? processed.length)
|
|
769
|
+
: processed.length;
|
|
632
770
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
633
771
|
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
634
772
|
const rangeStart = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
|
635
|
-
const rangeEnd =
|
|
773
|
+
const rangeEnd = isManual
|
|
636
774
|
? total === 0
|
|
637
775
|
? 0
|
|
638
776
|
: Math.min(rangeStart + processed.length - 1, total)
|
|
639
777
|
: Math.min(safePage * pageSize, total);
|
|
640
|
-
const paged =
|
|
778
|
+
const paged = isManual
|
|
641
779
|
? processed
|
|
642
780
|
: processed.slice((safePage - 1) * pageSize, safePage * pageSize);
|
|
781
|
+
// Loading state comes from the fetcher when it owns the data.
|
|
782
|
+
const effectiveLoading = fetching ? fetchedLoading : loading;
|
|
783
|
+
// Keep the current query fresh for post-mutation background refetches.
|
|
784
|
+
queryRef.current = {
|
|
785
|
+
page: safePage,
|
|
786
|
+
pageSize,
|
|
787
|
+
sort,
|
|
788
|
+
search: filter,
|
|
789
|
+
filters: filterValues,
|
|
790
|
+
};
|
|
643
791
|
|
|
644
792
|
// Reset to the first page when the filter or page size changes.
|
|
645
793
|
React.useEffect(() => {
|
|
@@ -652,16 +800,19 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
652
800
|
// apply on demand, not per keystroke. `filterValues` is read fresh here but
|
|
653
801
|
// deliberately left out of the deps for that reason.
|
|
654
802
|
React.useEffect(() => {
|
|
655
|
-
if (!
|
|
656
|
-
|
|
803
|
+
if (!isManual) return;
|
|
804
|
+
const query: ServerQuery<T> = {
|
|
657
805
|
page: safePage,
|
|
658
806
|
pageSize,
|
|
659
807
|
sort,
|
|
660
808
|
search: filter,
|
|
661
809
|
filters: filterValues,
|
|
662
|
-
}
|
|
810
|
+
};
|
|
811
|
+
// `fetcher` owns the fetch; otherwise hand the query to the consumer.
|
|
812
|
+
if (fetching) runFetch(query);
|
|
813
|
+
else onQueryChange?.(query);
|
|
663
814
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
664
|
-
}, [
|
|
815
|
+
}, [isManual, fetching, safePage, pageSize, sort, filter, onQueryChange, runFetch]);
|
|
665
816
|
|
|
666
817
|
// Cascading filter options: when the values change, drop any filter value no
|
|
667
818
|
// longer valid once its options recompute (e.g. changing Region invalidates a
|
|
@@ -1308,14 +1459,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1308
1459
|
setFilterValues({});
|
|
1309
1460
|
onFilter?.({});
|
|
1310
1461
|
setPage(1);
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1462
|
+
const q: ServerQuery<T> = {
|
|
1463
|
+
page: 1,
|
|
1464
|
+
pageSize,
|
|
1465
|
+
sort,
|
|
1466
|
+
search: filter,
|
|
1467
|
+
filters: {},
|
|
1468
|
+
};
|
|
1469
|
+
if (fetching) runFetch(q);
|
|
1470
|
+
else if (manual) onQueryChange?.(q);
|
|
1319
1471
|
}}
|
|
1320
1472
|
>
|
|
1321
1473
|
Clear
|
|
@@ -1325,14 +1477,15 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1325
1477
|
onClick={() => {
|
|
1326
1478
|
onFilter?.(filterValues);
|
|
1327
1479
|
setPage(1);
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1480
|
+
const q: ServerQuery<T> = {
|
|
1481
|
+
page: 1,
|
|
1482
|
+
pageSize,
|
|
1483
|
+
sort,
|
|
1484
|
+
search: filter,
|
|
1485
|
+
filters: filterValues,
|
|
1486
|
+
};
|
|
1487
|
+
if (fetching) runFetch(q);
|
|
1488
|
+
else if (manual) onQueryChange?.(q);
|
|
1336
1489
|
}}
|
|
1337
1490
|
>
|
|
1338
1491
|
<Search className="size-3.5" />
|
|
@@ -1544,7 +1697,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1544
1697
|
</TableRow>
|
|
1545
1698
|
</TableHeader>
|
|
1546
1699
|
<TableBody>
|
|
1547
|
-
{
|
|
1700
|
+
{effectiveLoading ? (
|
|
1548
1701
|
// Animated skeleton rows while data loads from the server.
|
|
1549
1702
|
Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
|
|
1550
1703
|
<TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useCallback, useRef, useState } from "react";
|
|
4
3
|
import {
|
|
5
4
|
BackpackIcon as TeamIcon,
|
|
6
5
|
PersonIcon as RoleIcon,
|
|
@@ -39,7 +38,7 @@ const ALL: Member[] = Array.from({ length: 213 }, (_, i) => ({
|
|
|
39
38
|
}));
|
|
40
39
|
|
|
41
40
|
// Simulated server endpoint: filter + sort + paginate, returning just the page
|
|
42
|
-
// (plus the total). Swap this for a real `fetch` to your API.
|
|
41
|
+
// (plus the total). Swap this for a real `fetch(url, { signal })` to your API.
|
|
43
42
|
function fetchPage(
|
|
44
43
|
q: ServerQuery<Member>,
|
|
45
44
|
): Promise<{ rows: Member[]; total: number }> {
|
|
@@ -100,59 +99,36 @@ const fields: RecordField<Member>[] = [
|
|
|
100
99
|
},
|
|
101
100
|
];
|
|
102
101
|
|
|
103
|
-
function ServerDataTable() {
|
|
104
|
-
// Server-side mode: RecordView reports the query; we fetch the page and feed
|
|
105
|
-
// back `data` + `rowCount` + `loading`. It never filters/sorts/paginates here.
|
|
106
|
-
const [data, setData] = useState<Member[]>([]);
|
|
107
|
-
const [rowCount, setRowCount] = useState(0);
|
|
108
|
-
const [loading, setLoading] = useState(true);
|
|
109
|
-
const reqId = useRef(0);
|
|
110
|
-
|
|
111
|
-
const handleQuery = useCallback((q: ServerQuery<Member>) => {
|
|
112
|
-
const id = ++reqId.current;
|
|
113
|
-
setLoading(true);
|
|
114
|
-
void fetchPage(q).then(({ rows, total }) => {
|
|
115
|
-
if (id !== reqId.current) return; // ignore out-of-order responses
|
|
116
|
-
setData(rows);
|
|
117
|
-
setRowCount(total);
|
|
118
|
-
setLoading(false);
|
|
119
|
-
});
|
|
120
|
-
}, []);
|
|
121
|
-
|
|
122
|
-
return (
|
|
123
|
-
<RecordView
|
|
124
|
-
title="Data Table"
|
|
125
|
-
singular="Member"
|
|
126
|
-
icon={TableIcon}
|
|
127
|
-
manual
|
|
128
|
-
rowCount={rowCount}
|
|
129
|
-
loading={loading}
|
|
130
|
-
onQueryChange={handleQuery}
|
|
131
|
-
fields={fields}
|
|
132
|
-
initialData={data}
|
|
133
|
-
data={data}
|
|
134
|
-
onDataChange={setData}
|
|
135
|
-
getPrimary={(row) => ({
|
|
136
|
-
title: row.name,
|
|
137
|
-
subtitle: `${row.team} · ${row.role}`,
|
|
138
|
-
initials: row.name.slice(0, 2).toUpperCase(),
|
|
139
|
-
})}
|
|
140
|
-
makeEmptyRow={() => ({
|
|
141
|
-
id: Date.now(),
|
|
142
|
-
name: "",
|
|
143
|
-
team: "",
|
|
144
|
-
role: "",
|
|
145
|
-
location: "",
|
|
146
|
-
commits: 0,
|
|
147
|
-
})}
|
|
148
|
-
/>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
102
|
export default function DataTablePage() {
|
|
103
|
+
// `fetcher` gives RecordView ownership of the read path: it calls the endpoint
|
|
104
|
+
// on every query change, manages data / rowCount / loading, and caches
|
|
105
|
+
// responses under `cacheKey`. With `persistKey`, returning to the tab restores
|
|
106
|
+
// the same page/sort/filters and hits the cache — instant, no refetch.
|
|
153
107
|
return (
|
|
154
108
|
<main className="h-full">
|
|
155
|
-
<
|
|
109
|
+
<RecordView
|
|
110
|
+
title="Data Table"
|
|
111
|
+
singular="Member"
|
|
112
|
+
icon={TableIcon}
|
|
113
|
+
persistKey="/data-table"
|
|
114
|
+
cacheKey="data-table"
|
|
115
|
+
fetcher={fetchPage}
|
|
116
|
+
fields={fields}
|
|
117
|
+
initialData={[]}
|
|
118
|
+
getPrimary={(row) => ({
|
|
119
|
+
title: row.name,
|
|
120
|
+
subtitle: `${row.team} · ${row.role}`,
|
|
121
|
+
initials: row.name.slice(0, 2).toUpperCase(),
|
|
122
|
+
})}
|
|
123
|
+
makeEmptyRow={() => ({
|
|
124
|
+
id: Date.now(),
|
|
125
|
+
name: "",
|
|
126
|
+
team: "",
|
|
127
|
+
role: "",
|
|
128
|
+
location: "",
|
|
129
|
+
commits: 0,
|
|
130
|
+
})}
|
|
131
|
+
/>
|
|
156
132
|
</main>
|
|
157
133
|
);
|
|
158
134
|
}
|