@viliha/vui-ui 1.12.0 → 1.13.1
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 +3 -1
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/record-view.tsx +93 -12
- package/src/theme.css +24 -0
- package/template/app/(app)/data-table/layout.tsx +15 -0
- package/template/app/(app)/data-table/page.tsx +158 -0
- package/template/app/_components/nav-config.ts +2 -0
- package/template/app/_components/open-tabs.tsx +8 -5
- package/template/app/_components/route-meta.ts +3 -0
package/AGENT.md
CHANGED
|
@@ -338,7 +338,9 @@ Sorting follows column visibility by default; use `sortable` to decouple them
|
|
|
338
338
|
|
|
339
339
|
Form controls come from the field: `options` → `Select`, `input: "combobox"` → a searchable `Combobox` (`@viliha/vui-ui/combobox`, use for long lists), `input: "number" | "date"` → native inputs. For anything the built-ins don't cover (checkbox, radio group, slider, custom widget), set `renderInput({ value, onChange, field, invalid })` on the field — it drops any component into the Add/Edit form while the field keeps its label, required mark, and Save validation. Don't hand-build a `<form>`.
|
|
340
340
|
|
|
341
|
-
Loading from a server: pass `loading` to `RecordView` while the fetch is in flight — it shows
|
|
341
|
+
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
|
+
|
|
343
|
+
Server-side (large tables): set `manual` and RecordView stops filtering/sorting/paginating `data` — it renders it as the current page and reports the query via `onQueryChange({ page, pageSize, sort, search, filters })`. Pair with `rowCount` (totals) and `loading`. In your handler, fetch and set `data` + `rowCount` + `loading`; guard out-of-order responses with a request-id ref and debounce chatty keyword changes. Don't reimplement pagination/sort yourself. Reference: the Data Table demo page (shadcn/ui section) hits a simulated backend.
|
|
342
344
|
|
|
343
345
|
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).
|
|
344
346
|
|
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,44 @@ 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.13.1 — 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **Keep-alive tabs now truly preserve a page's live state across tab switches**
|
|
15
|
+
(within `NEXT_PUBLIC_MAX_TABS`). The scaffold's `KeepAliveTabs` was overwriting
|
|
16
|
+
a route's cached element with Next's fresh `children` on every re-activation,
|
|
17
|
+
which remounted the page and threw away its state — so a server-backed table
|
|
18
|
+
(e.g. the Data Table demo) refetched every time you returned to its tab. It now
|
|
19
|
+
caches each route's element once and reuses it, so switching away and back keeps
|
|
20
|
+
the loaded data, scroll, and form state with no reload. Query-param routes
|
|
21
|
+
(`/organizations/edit?id=…`) still update via their own `useSearchParams`.
|
|
22
|
+
|
|
23
|
+
## 1.13.0 — 2026-07-26
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- **Server-side ("manual") mode for `RecordView`.** Set `manual` and RecordView
|
|
28
|
+
stops filtering/sorting/paginating `data` — it renders it as the current page
|
|
29
|
+
verbatim and reports the query via `onQueryChange({ page, pageSize, sort,
|
|
30
|
+
search, filters })` so your backend does the work. Pair with `rowCount` (drives
|
|
31
|
+
the footer + page count) and `loading`. Fires once on mount for the initial
|
|
32
|
+
load; per-field filters fire it on the Filter panel's Search/Clear. New exported
|
|
33
|
+
types: `SortState`, `ServerQuery<T>`. Default off — everything stays client-side.
|
|
34
|
+
- **Data Table demo page** in the scaffold (shadcn/ui section) — a full
|
|
35
|
+
server-side table against a simulated backend (pagination, sort, keyword +
|
|
36
|
+
per-field filter, loading shimmer, out-of-order-response guard).
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The loading skeleton now **shimmers left → right** (a `vui-shimmer` sweep in
|
|
41
|
+
`theme.css`) instead of a pulse — a clearer "records are loading" cue.
|
|
42
|
+
|
|
43
|
+
**For agents:** for a server-backed table use `manual` + `rowCount` +
|
|
44
|
+
`onQueryChange` (fetch → set `data`/`rowCount`/`loading`); guard stale
|
|
45
|
+
responses with a request-id ref. Don't reimplement pagination/sort. Demo:
|
|
46
|
+
the Data Table page. Docs: `/docs/data-table` (Server-side data).
|
|
47
|
+
|
|
10
48
|
## 1.12.0 — 2026-07-26
|
|
11
49
|
|
|
12
50
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
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
|
@@ -213,6 +213,21 @@ export type FilterValues<T> = Partial<
|
|
|
213
213
|
Record<Extract<keyof T, string>, string | string[]>
|
|
214
214
|
>;
|
|
215
215
|
|
|
216
|
+
/** Current sort — the field key and direction, or `null` for unsorted. */
|
|
217
|
+
export type SortState = { key: string; dir: "asc" | "desc" };
|
|
218
|
+
|
|
219
|
+
/** The full query state reported by `onQueryChange` in server (`manual`) mode —
|
|
220
|
+
* everything needed to build a request. `page` is 1-based. */
|
|
221
|
+
export type ServerQuery<T> = {
|
|
222
|
+
page: number;
|
|
223
|
+
pageSize: number;
|
|
224
|
+
sort: SortState | null;
|
|
225
|
+
/** The keyword box. */
|
|
226
|
+
search: string;
|
|
227
|
+
/** Per-field filter values (from `filterable` fields). */
|
|
228
|
+
filters: FilterValues<T>;
|
|
229
|
+
};
|
|
230
|
+
|
|
216
231
|
export interface RecordField<T> {
|
|
217
232
|
key: Extract<keyof T, string>;
|
|
218
233
|
label: string;
|
|
@@ -384,6 +399,20 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
384
399
|
* from the server (an initial fetch or a filter/refetch). Set it around your
|
|
385
400
|
* async load; the toolbar stays usable. */
|
|
386
401
|
loading?: boolean;
|
|
402
|
+
/** Server-side mode. When `true`, RecordView does NOT filter, sort, or
|
|
403
|
+
* paginate `data` — it renders `data` as the current page verbatim and reports
|
|
404
|
+
* query state via `onQueryChange`, so your backend does the work. Pair with
|
|
405
|
+
* `rowCount` (for totals), `loading`, and `onQueryChange`. Default `false`
|
|
406
|
+
* (everything client-side). */
|
|
407
|
+
manual?: boolean;
|
|
408
|
+
/** Total row count on the server — drives the pagination footer and page count
|
|
409
|
+
* in `manual` mode (RecordView can't infer it from a single page of `data`). */
|
|
410
|
+
rowCount?: number;
|
|
411
|
+
/** Server mode: called with the full query whenever page, page size, sort, or
|
|
412
|
+
* the keyword changes (and on the per-field Filter Search/Clear). Fetch and
|
|
413
|
+
* update `data` + `rowCount` + `loading` in response. Fires once on mount for
|
|
414
|
+
* the initial load; debounce inside if keyword changes are chatty. */
|
|
415
|
+
onQueryChange?: (query: ServerQuery<T>) => void;
|
|
387
416
|
}
|
|
388
417
|
|
|
389
418
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -407,6 +436,9 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
407
436
|
resizableColumns = false,
|
|
408
437
|
onFilter,
|
|
409
438
|
loading = false,
|
|
439
|
+
manual = false,
|
|
440
|
+
rowCount,
|
|
441
|
+
onQueryChange,
|
|
410
442
|
}: RecordViewProps<T>) {
|
|
411
443
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
412
444
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -554,6 +586,9 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
554
586
|
);
|
|
555
587
|
|
|
556
588
|
const processed = React.useMemo(() => {
|
|
589
|
+
// Server mode: `rows` is already the filtered/sorted current page — render
|
|
590
|
+
// it verbatim (no client-side filter/sort).
|
|
591
|
+
if (manual) return rows;
|
|
557
592
|
let out = rows;
|
|
558
593
|
const q = filter.trim().toLowerCase();
|
|
559
594
|
if (q) {
|
|
@@ -582,7 +617,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
582
617
|
});
|
|
583
618
|
}
|
|
584
619
|
return out;
|
|
585
|
-
}, [rows, filter, sort, fields, getPrimary]);
|
|
620
|
+
}, [manual, rows, filter, sort, fields, getPrimary]);
|
|
586
621
|
|
|
587
622
|
const activeRow = rows.find((r) => r.id === activeId) ?? null;
|
|
588
623
|
const deleteTarget =
|
|
@@ -591,17 +626,43 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
591
626
|
: null;
|
|
592
627
|
|
|
593
628
|
// Pagination (derived; `page` is clamped so it never points past the last page).
|
|
594
|
-
|
|
629
|
+
// Server mode: totals come from `rowCount`, and `data` is already this page —
|
|
630
|
+
// so render it whole (no slice) and size the range to what the server returned.
|
|
631
|
+
const total = manual ? (rowCount ?? processed.length) : processed.length;
|
|
632
|
+
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
595
633
|
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
596
|
-
const rangeStart =
|
|
597
|
-
const rangeEnd =
|
|
598
|
-
|
|
634
|
+
const rangeStart = total === 0 ? 0 : (safePage - 1) * pageSize + 1;
|
|
635
|
+
const rangeEnd = manual
|
|
636
|
+
? total === 0
|
|
637
|
+
? 0
|
|
638
|
+
: Math.min(rangeStart + processed.length - 1, total)
|
|
639
|
+
: Math.min(safePage * pageSize, total);
|
|
640
|
+
const paged = manual
|
|
641
|
+
? processed
|
|
642
|
+
: processed.slice((safePage - 1) * pageSize, safePage * pageSize);
|
|
599
643
|
|
|
600
644
|
// Reset to the first page when the filter or page size changes.
|
|
601
645
|
React.useEffect(() => {
|
|
602
646
|
setPage(1);
|
|
603
647
|
}, [filter, pageSize, setPage]);
|
|
604
648
|
|
|
649
|
+
// Server mode: report the query so the consumer can fetch. Fires on page,
|
|
650
|
+
// size, sort, and keyword changes (and once on mount for the initial load).
|
|
651
|
+
// Per-field filters emit via the Filter panel's Search/Clear instead, so they
|
|
652
|
+
// apply on demand, not per keystroke. `filterValues` is read fresh here but
|
|
653
|
+
// deliberately left out of the deps for that reason.
|
|
654
|
+
React.useEffect(() => {
|
|
655
|
+
if (!manual) return;
|
|
656
|
+
onQueryChange?.({
|
|
657
|
+
page: safePage,
|
|
658
|
+
pageSize,
|
|
659
|
+
sort,
|
|
660
|
+
search: filter,
|
|
661
|
+
filters: filterValues,
|
|
662
|
+
});
|
|
663
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
664
|
+
}, [manual, safePage, pageSize, sort, filter, onQueryChange]);
|
|
665
|
+
|
|
605
666
|
// Cascading filter options: when the values change, drop any filter value no
|
|
606
667
|
// longer valid once its options recompute (e.g. changing Region invalidates a
|
|
607
668
|
// Country filter). Only function-options filters cascade. Strings clear; multi
|
|
@@ -1246,13 +1307,33 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1246
1307
|
onClick={() => {
|
|
1247
1308
|
setFilterValues({});
|
|
1248
1309
|
onFilter?.({});
|
|
1310
|
+
setPage(1);
|
|
1311
|
+
if (manual)
|
|
1312
|
+
onQueryChange?.({
|
|
1313
|
+
page: 1,
|
|
1314
|
+
pageSize,
|
|
1315
|
+
sort,
|
|
1316
|
+
search: filter,
|
|
1317
|
+
filters: {},
|
|
1318
|
+
});
|
|
1249
1319
|
}}
|
|
1250
1320
|
>
|
|
1251
1321
|
Clear
|
|
1252
1322
|
</Button>
|
|
1253
1323
|
<Button
|
|
1254
1324
|
variant="primary"
|
|
1255
|
-
onClick={() =>
|
|
1325
|
+
onClick={() => {
|
|
1326
|
+
onFilter?.(filterValues);
|
|
1327
|
+
setPage(1);
|
|
1328
|
+
if (manual)
|
|
1329
|
+
onQueryChange?.({
|
|
1330
|
+
page: 1,
|
|
1331
|
+
pageSize,
|
|
1332
|
+
sort,
|
|
1333
|
+
search: filter,
|
|
1334
|
+
filters: filterValues,
|
|
1335
|
+
});
|
|
1336
|
+
}}
|
|
1256
1337
|
>
|
|
1257
1338
|
<Search className="size-3.5" />
|
|
1258
1339
|
Search
|
|
@@ -1341,7 +1422,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1341
1422
|
))}
|
|
1342
1423
|
</Dropdown>
|
|
1343
1424
|
<span className="whitespace-nowrap px-1 tabular-nums">
|
|
1344
|
-
{rangeStart}–{rangeEnd} of {
|
|
1425
|
+
{rangeStart}–{rangeEnd} of {total}
|
|
1345
1426
|
</span>
|
|
1346
1427
|
<button
|
|
1347
1428
|
type="button"
|
|
@@ -1468,22 +1549,22 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1468
1549
|
Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
|
|
1469
1550
|
<TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
|
|
1470
1551
|
<TableCell style={{ width: CHECKBOX_W }}>
|
|
1471
|
-
<div className="mx-2 size-4
|
|
1552
|
+
<div className="mx-2 size-4 vui-shimmer rounded" />
|
|
1472
1553
|
</TableCell>
|
|
1473
1554
|
<TableCell style={{ width: colWidths[NAME_COL] }}>
|
|
1474
1555
|
<div className="flex items-center gap-2">
|
|
1475
|
-
<div className="size-7 shrink-0
|
|
1476
|
-
<div className="h-3.5 w-32
|
|
1556
|
+
<div className="size-7 shrink-0 vui-shimmer rounded-full" />
|
|
1557
|
+
<div className="h-3.5 w-32 vui-shimmer rounded" />
|
|
1477
1558
|
</div>
|
|
1478
1559
|
</TableCell>
|
|
1479
1560
|
{visibleFields.map((f) => (
|
|
1480
1561
|
<TableCell key={f.key} style={{ width: colWidths[f.key] }}>
|
|
1481
|
-
<div className="h-3.5 w-20
|
|
1562
|
+
<div className="h-3.5 w-20 vui-shimmer rounded" />
|
|
1482
1563
|
</TableCell>
|
|
1483
1564
|
))}
|
|
1484
1565
|
<TableCell aria-hidden="true" className="border-r-0" />
|
|
1485
1566
|
<TableCell style={{ width: ACTIONS_W }}>
|
|
1486
|
-
<div className="mx-auto h-4 w-8
|
|
1567
|
+
<div className="mx-auto h-4 w-8 vui-shimmer rounded" />
|
|
1487
1568
|
</TableCell>
|
|
1488
1569
|
</TableRow>
|
|
1489
1570
|
))
|
package/src/theme.css
CHANGED
|
@@ -273,6 +273,30 @@
|
|
|
273
273
|
transform-origin: var(--vui-pop-origin, top);
|
|
274
274
|
}
|
|
275
275
|
|
|
276
|
+
/* Loading skeleton: a highlight band sweeping left → right over a muted base,
|
|
277
|
+
the "records are loading" cue. Used by RecordView's loading skeleton rows.
|
|
278
|
+
(prefers-reduced-motion is respected by the global clamp above.) */
|
|
279
|
+
@keyframes vui-shimmer {
|
|
280
|
+
from {
|
|
281
|
+
background-position: -150% 0;
|
|
282
|
+
}
|
|
283
|
+
to {
|
|
284
|
+
background-position: 150% 0;
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
.vui-shimmer {
|
|
288
|
+
background-color: var(--muted);
|
|
289
|
+
background-image: linear-gradient(
|
|
290
|
+
90deg,
|
|
291
|
+
var(--muted) 0%,
|
|
292
|
+
color-mix(in oklab, var(--foreground) 12%, var(--muted)) 50%,
|
|
293
|
+
var(--muted) 100%
|
|
294
|
+
);
|
|
295
|
+
background-size: 150% 100%;
|
|
296
|
+
background-repeat: no-repeat;
|
|
297
|
+
animation: vui-shimmer 1.5s ease-in-out infinite;
|
|
298
|
+
}
|
|
299
|
+
|
|
276
300
|
@keyframes vui-slide-out-right {
|
|
277
301
|
from {
|
|
278
302
|
transform: translateX(0);
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
export const metadata: Metadata = {
|
|
4
|
+
title: "Data Table",
|
|
5
|
+
description:
|
|
6
|
+
"Server-side data table demo — pagination, sorting, keyword and per-field filtering handled by the backend, with a loading shimmer, built on RecordView.",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export default function DataTableLayout({
|
|
10
|
+
children,
|
|
11
|
+
}: {
|
|
12
|
+
children: React.ReactNode;
|
|
13
|
+
}) {
|
|
14
|
+
return children;
|
|
15
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useCallback, useRef, useState } from "react";
|
|
4
|
+
import {
|
|
5
|
+
BackpackIcon as TeamIcon,
|
|
6
|
+
PersonIcon as RoleIcon,
|
|
7
|
+
SewingPinFilledIcon as LocationIcon,
|
|
8
|
+
TableIcon,
|
|
9
|
+
} from "@radix-ui/react-icons";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
RecordView,
|
|
13
|
+
type RecordField,
|
|
14
|
+
type ServerQuery,
|
|
15
|
+
} from "@viliha/vui-ui/record-view";
|
|
16
|
+
|
|
17
|
+
// A self-contained demo type + dataset. In a real app this lives in your
|
|
18
|
+
// database; here it stands in for the backend table so pagination is visible.
|
|
19
|
+
type Member = {
|
|
20
|
+
id: number;
|
|
21
|
+
name: string;
|
|
22
|
+
team: string;
|
|
23
|
+
role: string;
|
|
24
|
+
location: string;
|
|
25
|
+
commits: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const TEAMS = ["Platform", "Growth", "Design", "Data", "Mobile", "Security"];
|
|
29
|
+
const ROLES = ["Engineer", "Manager", "Designer", "Analyst", "Lead"];
|
|
30
|
+
const CITIES = ["Seattle", "Tokyo", "Berlin", "Austin", "Sydney", "London"];
|
|
31
|
+
|
|
32
|
+
const ALL: Member[] = Array.from({ length: 213 }, (_, i) => ({
|
|
33
|
+
id: i + 1,
|
|
34
|
+
name: `Member ${String(i + 1).padStart(3, "0")}`,
|
|
35
|
+
team: TEAMS[i % TEAMS.length]!,
|
|
36
|
+
role: ROLES[i % ROLES.length]!,
|
|
37
|
+
location: CITIES[i % CITIES.length]!,
|
|
38
|
+
commits: (i * 7) % 500,
|
|
39
|
+
}));
|
|
40
|
+
|
|
41
|
+
// Simulated server endpoint: filter + sort + paginate, returning just the page
|
|
42
|
+
// (plus the total). Swap this for a real `fetch` to your API.
|
|
43
|
+
function fetchPage(
|
|
44
|
+
q: ServerQuery<Member>,
|
|
45
|
+
): Promise<{ rows: Member[]; total: number }> {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
let out = ALL;
|
|
49
|
+
const search = q.search.trim().toLowerCase();
|
|
50
|
+
if (search) {
|
|
51
|
+
out = out.filter((m) =>
|
|
52
|
+
[m.name, m.team, m.role, m.location].some((v) =>
|
|
53
|
+
v.toLowerCase().includes(search),
|
|
54
|
+
),
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const team = q.filters.team;
|
|
58
|
+
if (typeof team === "string" && team) {
|
|
59
|
+
out = out.filter((m) => m.team === team);
|
|
60
|
+
}
|
|
61
|
+
if (q.sort) {
|
|
62
|
+
const { key, dir } = q.sort;
|
|
63
|
+
out = [...out].sort((a, b) => {
|
|
64
|
+
const av = a[key as keyof Member];
|
|
65
|
+
const bv = b[key as keyof Member];
|
|
66
|
+
const cmp =
|
|
67
|
+
typeof av === "number" && typeof bv === "number"
|
|
68
|
+
? av - bv
|
|
69
|
+
: String(av ?? "").localeCompare(String(bv ?? ""));
|
|
70
|
+
return dir === "asc" ? cmp : -cmp;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
const total = out.length;
|
|
74
|
+
const start = (q.page - 1) * q.pageSize;
|
|
75
|
+
resolve({ rows: out.slice(start, start + q.pageSize), total });
|
|
76
|
+
}, 500);
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const fields: RecordField<Member>[] = [
|
|
81
|
+
{ key: "name", label: "Name", editable: true, required: true, group: "General", hideInTable: true, sortable: true },
|
|
82
|
+
{
|
|
83
|
+
key: "team",
|
|
84
|
+
label: "Team",
|
|
85
|
+
icon: TeamIcon,
|
|
86
|
+
editable: true,
|
|
87
|
+
group: "General",
|
|
88
|
+
filterable: {
|
|
89
|
+
control: "select",
|
|
90
|
+
options: TEAMS.map((t) => ({ value: t, label: t })),
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
{ key: "role", label: "Role", icon: RoleIcon, editable: true, group: "General" },
|
|
94
|
+
{ key: "location", label: "Location", icon: LocationIcon, editable: true, group: "General" },
|
|
95
|
+
{
|
|
96
|
+
key: "commits",
|
|
97
|
+
label: "Commits",
|
|
98
|
+
group: "System",
|
|
99
|
+
render: (row) => <span className="tabular-nums">{row.commits}</span>,
|
|
100
|
+
},
|
|
101
|
+
];
|
|
102
|
+
|
|
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
|
+
export default function DataTablePage() {
|
|
153
|
+
return (
|
|
154
|
+
<main className="h-full">
|
|
155
|
+
<ServerDataTable />
|
|
156
|
+
</main>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
RowsIcon as StepsIcon,
|
|
25
25
|
SewingPinFilledIcon as MapPin,
|
|
26
26
|
Share2Icon as Network,
|
|
27
|
+
TableIcon,
|
|
27
28
|
TargetIcon as Target,
|
|
28
29
|
TextIcon as Languages,
|
|
29
30
|
TokensIcon as Coins,
|
|
@@ -75,6 +76,7 @@ export const NAV: NavSection[] = [
|
|
|
75
76
|
title: "shadcn/ui",
|
|
76
77
|
items: [
|
|
77
78
|
{ label: "Components", href: "/components", icon: Blocks, color: "text-indigo-500" },
|
|
79
|
+
{ label: "Data Table", href: "/data-table", icon: TableIcon, color: "text-amber-500" },
|
|
78
80
|
{ label: "Forms", href: "/forms", icon: FormInput, color: "text-teal-500" },
|
|
79
81
|
{ label: "Steps", href: "/steps", icon: StepsIcon, color: "text-violet-500" },
|
|
80
82
|
{ label: "Calendar", href: "/calendar", icon: CalendarIcon, color: "text-rose-500" },
|
|
@@ -503,11 +503,14 @@ export function KeepAliveTabs({ children }: { children: React.ReactNode }) {
|
|
|
503
503
|
const active = tabKey(usePathname());
|
|
504
504
|
const { tabs } = useOpenTabs();
|
|
505
505
|
const cache = React.useRef(new Map<string, React.ReactNode>());
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
510
|
-
|
|
506
|
+
// Cache each route's element ONCE, on first activation, and reuse it — never
|
|
507
|
+
// overwrite on re-activation. Reusing the same element reference is what lets
|
|
508
|
+
// React keep the mounted instance (and its loaded data / scroll / form state)
|
|
509
|
+
// across tab switches; overwriting with Next's fresh `children` would remount
|
|
510
|
+
// the subtree and reload everything. Routes that vary by query param (e.g.
|
|
511
|
+
// /organizations/edit?id=…) share one tab and update themselves via
|
|
512
|
+
// `useSearchParams`, so they don't need a refresh here.
|
|
513
|
+
if (!cache.current.has(active)) cache.current.set(active, children);
|
|
511
514
|
// Drop entries for tabs that have been closed.
|
|
512
515
|
const open = new Set(tabs.map((t) => t.href));
|
|
513
516
|
for (const key of [...cache.current.keys()]) {
|
|
@@ -6,6 +6,7 @@ export const SEGMENT_LABELS: Record<string, string> = {
|
|
|
6
6
|
dashboard: "Home",
|
|
7
7
|
charts: "Charts",
|
|
8
8
|
components: "Components",
|
|
9
|
+
"data-table": "Data Table",
|
|
9
10
|
forms: "Forms",
|
|
10
11
|
steps: "Steps",
|
|
11
12
|
calendar: "Calendar",
|
|
@@ -36,6 +37,7 @@ export const ROUTE_COLORS: Record<string, string> = {
|
|
|
36
37
|
"/dashboard": "text-blue-500",
|
|
37
38
|
"/charts": "text-fuchsia-500",
|
|
38
39
|
"/components": "text-indigo-500",
|
|
40
|
+
"/data-table": "text-amber-500",
|
|
39
41
|
"/forms": "text-teal-500",
|
|
40
42
|
"/steps": "text-violet-500",
|
|
41
43
|
"/calendar": "text-rose-500",
|
|
@@ -65,6 +67,7 @@ export const ROUTE_ACCENT: Record<string, string> = {
|
|
|
65
67
|
"/dashboard": "#3b82f6",
|
|
66
68
|
"/charts": "#d946ef",
|
|
67
69
|
"/components": "#6366f1",
|
|
70
|
+
"/data-table": "#f59e0b",
|
|
68
71
|
"/forms": "#14b8a6",
|
|
69
72
|
"/steps": "#8b5cf6",
|
|
70
73
|
"/calendar": "#f43f5e",
|