@viliha/vui-ui 1.11.1 → 1.13.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 +4 -0
- package/CHANGELOG.md +38 -0
- package/package.json +1 -1
- package/src/record-view.tsx +118 -8
- 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/(app)/markets/markets-table.tsx +17 -1
- package/template/app/_components/nav-config.ts +2 -0
- package/template/app/_components/route-meta.ts +3 -0
package/AGENT.md
CHANGED
|
@@ -338,6 +338,10 @@ 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 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.
|
|
344
|
+
|
|
341
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).
|
|
342
346
|
|
|
343
347
|
## Filtering
|
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.0 — 2026-07-26
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- **Server-side ("manual") mode for `RecordView`.** Set `manual` and RecordView
|
|
15
|
+
stops filtering/sorting/paginating `data` — it renders it as the current page
|
|
16
|
+
verbatim and reports the query via `onQueryChange({ page, pageSize, sort,
|
|
17
|
+
search, filters })` so your backend does the work. Pair with `rowCount` (drives
|
|
18
|
+
the footer + page count) and `loading`. Fires once on mount for the initial
|
|
19
|
+
load; per-field filters fire it on the Filter panel's Search/Clear. New exported
|
|
20
|
+
types: `SortState`, `ServerQuery<T>`. Default off — everything stays client-side.
|
|
21
|
+
- **Data Table demo page** in the scaffold (shadcn/ui section) — a full
|
|
22
|
+
server-side table against a simulated backend (pagination, sort, keyword +
|
|
23
|
+
per-field filter, loading shimmer, out-of-order-response guard).
|
|
24
|
+
|
|
25
|
+
### Changed
|
|
26
|
+
|
|
27
|
+
- The loading skeleton now **shimmers left → right** (a `vui-shimmer` sweep in
|
|
28
|
+
`theme.css`) instead of a pulse — a clearer "records are loading" cue.
|
|
29
|
+
|
|
30
|
+
**For agents:** for a server-backed table use `manual` + `rowCount` +
|
|
31
|
+
`onQueryChange` (fetch → set `data`/`rowCount`/`loading`); guard stale
|
|
32
|
+
responses with a request-id ref. Don't reimplement pagination/sort. Demo:
|
|
33
|
+
the Data Table page. Docs: `/docs/data-table` (Server-side data).
|
|
34
|
+
|
|
35
|
+
## 1.12.0 — 2026-07-26
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`RecordView` `loading` prop** — while `true`, the table body renders animated
|
|
40
|
+
skeleton rows (matched to the columns) instead of an empty-state flash, for
|
|
41
|
+
slow server loads (initial fetch or refetch). The toolbar stays usable; clear
|
|
42
|
+
`loading` when the data arrives.
|
|
43
|
+
|
|
44
|
+
**For agents:** wrap your fetch with `loading` (`setLoading(true)` → fetch →
|
|
45
|
+
`finally(() => setLoading(false))`); don't hand-roll a spinner overlay. Demo:
|
|
46
|
+
`markets` simulates a load on first visit. Docs: `/docs/data-table`.
|
|
47
|
+
|
|
10
48
|
## 1.11.1 — 2026-07-26
|
|
11
49
|
|
|
12
50
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@viliha/vui-ui",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.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
|
@@ -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;
|
|
@@ -380,6 +395,24 @@ interface RecordViewProps<T extends { id: RowId }> {
|
|
|
380
395
|
* or client-side filtering here. In per-field mode the panel does not match
|
|
381
396
|
* rows itself, so the behavior is entirely yours. */
|
|
382
397
|
onFilter?: (values: FilterValues<T>) => void;
|
|
398
|
+
/** Show animated skeleton rows instead of the table body while data loads
|
|
399
|
+
* from the server (an initial fetch or a filter/refetch). Set it around your
|
|
400
|
+
* async load; the toolbar stays usable. */
|
|
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;
|
|
383
416
|
}
|
|
384
417
|
|
|
385
418
|
export function RecordView<T extends { id: RowId }>({
|
|
@@ -402,6 +435,10 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
402
435
|
persistKey,
|
|
403
436
|
resizableColumns = false,
|
|
404
437
|
onFilter,
|
|
438
|
+
loading = false,
|
|
439
|
+
manual = false,
|
|
440
|
+
rowCount,
|
|
441
|
+
onQueryChange,
|
|
405
442
|
}: RecordViewProps<T>) {
|
|
406
443
|
const { titleLeading } = React.useContext(PageChromeContext);
|
|
407
444
|
// Surface the page title/icon in the app's global top bar.
|
|
@@ -549,6 +586,9 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
549
586
|
);
|
|
550
587
|
|
|
551
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;
|
|
552
592
|
let out = rows;
|
|
553
593
|
const q = filter.trim().toLowerCase();
|
|
554
594
|
if (q) {
|
|
@@ -577,7 +617,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
577
617
|
});
|
|
578
618
|
}
|
|
579
619
|
return out;
|
|
580
|
-
}, [rows, filter, sort, fields, getPrimary]);
|
|
620
|
+
}, [manual, rows, filter, sort, fields, getPrimary]);
|
|
581
621
|
|
|
582
622
|
const activeRow = rows.find((r) => r.id === activeId) ?? null;
|
|
583
623
|
const deleteTarget =
|
|
@@ -586,17 +626,43 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
586
626
|
: null;
|
|
587
627
|
|
|
588
628
|
// Pagination (derived; `page` is clamped so it never points past the last page).
|
|
589
|
-
|
|
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));
|
|
590
633
|
const safePage = Math.min(Math.max(1, page), totalPages);
|
|
591
|
-
const rangeStart =
|
|
592
|
-
const rangeEnd =
|
|
593
|
-
|
|
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);
|
|
594
643
|
|
|
595
644
|
// Reset to the first page when the filter or page size changes.
|
|
596
645
|
React.useEffect(() => {
|
|
597
646
|
setPage(1);
|
|
598
647
|
}, [filter, pageSize, setPage]);
|
|
599
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
|
+
|
|
600
666
|
// Cascading filter options: when the values change, drop any filter value no
|
|
601
667
|
// longer valid once its options recompute (e.g. changing Region invalidates a
|
|
602
668
|
// Country filter). Only function-options filters cascade. Strings clear; multi
|
|
@@ -1241,13 +1307,33 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1241
1307
|
onClick={() => {
|
|
1242
1308
|
setFilterValues({});
|
|
1243
1309
|
onFilter?.({});
|
|
1310
|
+
setPage(1);
|
|
1311
|
+
if (manual)
|
|
1312
|
+
onQueryChange?.({
|
|
1313
|
+
page: 1,
|
|
1314
|
+
pageSize,
|
|
1315
|
+
sort,
|
|
1316
|
+
search: filter,
|
|
1317
|
+
filters: {},
|
|
1318
|
+
});
|
|
1244
1319
|
}}
|
|
1245
1320
|
>
|
|
1246
1321
|
Clear
|
|
1247
1322
|
</Button>
|
|
1248
1323
|
<Button
|
|
1249
1324
|
variant="primary"
|
|
1250
|
-
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
|
+
}}
|
|
1251
1337
|
>
|
|
1252
1338
|
<Search className="size-3.5" />
|
|
1253
1339
|
Search
|
|
@@ -1336,7 +1422,7 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1336
1422
|
))}
|
|
1337
1423
|
</Dropdown>
|
|
1338
1424
|
<span className="whitespace-nowrap px-1 tabular-nums">
|
|
1339
|
-
{rangeStart}–{rangeEnd} of {
|
|
1425
|
+
{rangeStart}–{rangeEnd} of {total}
|
|
1340
1426
|
</span>
|
|
1341
1427
|
<button
|
|
1342
1428
|
type="button"
|
|
@@ -1458,7 +1544,31 @@ export function RecordView<T extends { id: RowId }>({
|
|
|
1458
1544
|
</TableRow>
|
|
1459
1545
|
</TableHeader>
|
|
1460
1546
|
<TableBody>
|
|
1461
|
-
{
|
|
1547
|
+
{loading ? (
|
|
1548
|
+
// Animated skeleton rows while data loads from the server.
|
|
1549
|
+
Array.from({ length: Math.min(pageSize, 8) }).map((_, i) => (
|
|
1550
|
+
<TableRow key={`skeleton-${i}`} className="hover:bg-transparent">
|
|
1551
|
+
<TableCell style={{ width: CHECKBOX_W }}>
|
|
1552
|
+
<div className="mx-2 size-4 vui-shimmer rounded" />
|
|
1553
|
+
</TableCell>
|
|
1554
|
+
<TableCell style={{ width: colWidths[NAME_COL] }}>
|
|
1555
|
+
<div className="flex items-center gap-2">
|
|
1556
|
+
<div className="size-7 shrink-0 vui-shimmer rounded-full" />
|
|
1557
|
+
<div className="h-3.5 w-32 vui-shimmer rounded" />
|
|
1558
|
+
</div>
|
|
1559
|
+
</TableCell>
|
|
1560
|
+
{visibleFields.map((f) => (
|
|
1561
|
+
<TableCell key={f.key} style={{ width: colWidths[f.key] }}>
|
|
1562
|
+
<div className="h-3.5 w-20 vui-shimmer rounded" />
|
|
1563
|
+
</TableCell>
|
|
1564
|
+
))}
|
|
1565
|
+
<TableCell aria-hidden="true" className="border-r-0" />
|
|
1566
|
+
<TableCell style={{ width: ACTIONS_W }}>
|
|
1567
|
+
<div className="mx-auto h-4 w-8 vui-shimmer rounded" />
|
|
1568
|
+
</TableCell>
|
|
1569
|
+
</TableRow>
|
|
1570
|
+
))
|
|
1571
|
+
) : processed.length ? (
|
|
1462
1572
|
paged.map((row) => {
|
|
1463
1573
|
const primary = getPrimary(row);
|
|
1464
1574
|
return (
|
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
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
3
4
|
import {
|
|
4
5
|
CubeIcon as Building,
|
|
5
6
|
GlobeIcon as Compass,
|
|
@@ -51,13 +52,28 @@ const fields: RecordField<Market>[] = [
|
|
|
51
52
|
];
|
|
52
53
|
|
|
53
54
|
export function MarketsTable() {
|
|
55
|
+
// Demo: simulate loading records from a server so the skeleton state shows on
|
|
56
|
+
// first visit. In a real app, set `loading` around your fetch/refetch.
|
|
57
|
+
const [data, setData] = useState<Market[]>([]);
|
|
58
|
+
const [loading, setLoading] = useState(true);
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
const t = setTimeout(() => {
|
|
61
|
+
setData(markets);
|
|
62
|
+
setLoading(false);
|
|
63
|
+
}, 900);
|
|
64
|
+
return () => clearTimeout(t);
|
|
65
|
+
}, []);
|
|
66
|
+
|
|
54
67
|
return (
|
|
55
68
|
<RecordView
|
|
56
69
|
title="Markets"
|
|
57
70
|
singular="Market"
|
|
58
71
|
icon={MapPin}
|
|
72
|
+
loading={loading}
|
|
59
73
|
fields={fields}
|
|
60
|
-
initialData={
|
|
74
|
+
initialData={data}
|
|
75
|
+
data={data}
|
|
76
|
+
onDataChange={setData}
|
|
61
77
|
getPrimary={(row) => ({
|
|
62
78
|
title: row.name,
|
|
63
79
|
subtitle: row.organization,
|
|
@@ -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" },
|
|
@@ -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",
|