@tristan2828/ui-foundation 3.10.0 → 3.11.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/conventions/docs/add-an-entity.md +6 -0
- package/conventions/docs/entity-plan-template.md +13 -0
- package/dist/components/app/data-table.d.ts +9 -1
- package/dist/components/app/data-table.js +22 -2
- package/dist/hooks/table-url-changes.d.ts +7 -0
- package/dist/hooks/table-url-changes.js +14 -0
- package/dist/hooks/use-table-url-state.d.ts +2 -1
- package/dist/hooks/use-table-url-state.js +9 -2
- package/package.json +1 -1
|
@@ -353,6 +353,12 @@ is a filled-in example while the Widgets demo is still here).
|
|
|
353
353
|
|
|
354
354
|
## What not to copy
|
|
355
355
|
|
|
356
|
+
- The saved-views row in `widgets-table.tsx` (`VIEWS`, the "Saved views"
|
|
357
|
+
button group and `visibleColumns`) unless the plan's List screen lists
|
|
358
|
+
saved views. When it does, copy it: `useTableUrlState`'s third argument
|
|
359
|
+
takes the views, `activeView` says which one the filters are, and that
|
|
360
|
+
view's `columns` go to `DataTable`'s `visibleColumns` (the table's own
|
|
361
|
+
default list when no view is active, or `undefined` for every column).
|
|
356
362
|
- `src/api/gateway/widgets.ts`, `src/mocks/data.ts`, `src/mocks/handlers.ts`
|
|
357
363
|
are demo-domain content — write the entity's own versions rather than
|
|
358
364
|
adapting these by find-and-replace.
|
|
@@ -92,6 +92,19 @@ per list; delete the section if there are none.
|
|
|
92
92
|
|
|
93
93
|
- Default sort:
|
|
94
94
|
- Page size: 10 unless there's a reason.
|
|
95
|
+
- Saved views (optional; delete if none): one row per view, a button
|
|
96
|
+
above the filters that sets every filter and the sort in one press.
|
|
97
|
+
Columns: blank for the table's usual columns, or the exact columns the
|
|
98
|
+
view shows, for a view of one kind of record that shows a field only
|
|
99
|
+
that kind uses. The columns last while the view's filters do; any other
|
|
100
|
+
filter puts the usual columns back. If the usual columns should leave
|
|
101
|
+
out a field only a view shows, say so here.
|
|
102
|
+
|
|
103
|
+
| View | Filters | Sort | Columns |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| All tasks | none | dueDate asc | |
|
|
106
|
+
| Bugs | kind = bug | dueDate asc | title, severity, assignee, dueDate |
|
|
107
|
+
|
|
95
108
|
- Anything else the table must show or do:
|
|
96
109
|
|
|
97
110
|
## View screen
|
|
@@ -52,5 +52,13 @@ export type DataTableProps<TData extends Record<string, unknown>> = {
|
|
|
52
52
|
* unaffected. See conventions/docs/entity-plan-template.md "Default sort".
|
|
53
53
|
*/
|
|
54
54
|
enableSortingRemoval?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* The ids of the columns to show; the rest are hidden. They keep their
|
|
57
|
+
* order in `columns`. Leave undefined to show every column. A table
|
|
58
|
+
* with saved views passes the active view's `columns`
|
|
59
|
+
* (useTableUrlState's `activeView`), or its own default list when no
|
|
60
|
+
* view is active, which can leave out a column only one view shows.
|
|
61
|
+
*/
|
|
62
|
+
visibleColumns?: readonly string[];
|
|
55
63
|
};
|
|
56
|
-
export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
64
|
+
export declare function DataTable<TData extends Record<string, unknown>>({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }: DataTableProps<TData>): import("react").JSX.Element;
|
|
@@ -20,6 +20,20 @@ import { Button } from '../ui/button.js';
|
|
|
20
20
|
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from '../ui/empty.js';
|
|
21
21
|
import { Skeleton } from '../ui/skeleton.js';
|
|
22
22
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../ui/table.js';
|
|
23
|
+
// A column def's id as TanStack resolves it (constructColumn): `id`, then
|
|
24
|
+
// `accessorKey` with dots as underscores, then a string header. Group
|
|
25
|
+
// columns are looked through to their leaves, which visibility is keyed by.
|
|
26
|
+
function leafColumnIds(columns) {
|
|
27
|
+
return columns.flatMap((column) => {
|
|
28
|
+
if ('columns' in column && column.columns)
|
|
29
|
+
return leafColumnIds(column.columns);
|
|
30
|
+
const accessorKey = 'accessorKey' in column ? column.accessorKey : undefined;
|
|
31
|
+
const id = column.id ??
|
|
32
|
+
(accessorKey === undefined ? undefined : String(accessorKey).replaceAll('.', '_')) ??
|
|
33
|
+
(typeof column.header === 'string' ? column.header : undefined);
|
|
34
|
+
return id === undefined ? [] : [id];
|
|
35
|
+
});
|
|
36
|
+
}
|
|
23
37
|
function TableSkeleton({ columnCount }) {
|
|
24
38
|
return (_jsx("div", { className: "flex flex-col gap-2 p-2", "data-state": "loading", children: Array.from({ length: 5 }, (_, row) => (_jsx("div", { className: "flex gap-4", children: Array.from({ length: columnCount }, (_, col) => (_jsx(Skeleton, { className: "h-8 flex-1" }, col))) }, row))) }));
|
|
25
39
|
}
|
|
@@ -109,8 +123,13 @@ function useBottomScrollbar(containerRef, isSuccessView) {
|
|
|
109
123
|
}, [containerRef, isSuccessView, isOverflowing]);
|
|
110
124
|
return { barRef, isOverflowing, scrollWidth };
|
|
111
125
|
}
|
|
112
|
-
export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, }) {
|
|
126
|
+
export function DataTable({ columns, data, total, page, pageSize, onPageChange, sorting, onSortingChange, isLoading, error, onRetry, emptyTitle, emptyDescription, emptyAction, toolbar, getRowId, pinFirstColumn, pinLastColumn, enableSortingRemoval, visibleColumns, }) {
|
|
113
127
|
const pageCount = Math.max(1, Math.ceil(total / pageSize));
|
|
128
|
+
// TanStack treats a column missing from this map as visible, so every
|
|
129
|
+
// column gets an entry once a list is given.
|
|
130
|
+
const columnVisibility = visibleColumns
|
|
131
|
+
? Object.fromEntries(leafColumnIds(columns).map((id) => [id, visibleColumns.includes(id)]))
|
|
132
|
+
: {};
|
|
114
133
|
const table = useLegacyTable({
|
|
115
134
|
data,
|
|
116
135
|
columns,
|
|
@@ -118,6 +137,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
118
137
|
state: {
|
|
119
138
|
sorting,
|
|
120
139
|
pagination: { pageIndex: page - 1, pageSize },
|
|
140
|
+
columnVisibility,
|
|
121
141
|
},
|
|
122
142
|
manualSorting: true,
|
|
123
143
|
...(enableSortingRemoval === undefined ? {} : { enableSortingRemoval }),
|
|
@@ -143,7 +163,7 @@ export function DataTable({ columns, data, total, page, pageSize, onPageChange,
|
|
|
143
163
|
const isSuccessView = !error && !isLoading && !isPastLastPage && !isEmpty;
|
|
144
164
|
const containerRef = useRef(null);
|
|
145
165
|
const { barRef, isOverflowing, scrollWidth } = useBottomScrollbar(containerRef, Boolean(pinFirstColumn) && isSuccessView);
|
|
146
|
-
return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount:
|
|
166
|
+
return (_jsxs("div", { className: "flex flex-col gap-4", children: [toolbar, error ? (_jsx(ErrorState, { error: error, onRetry: onRetry })) : isLoading || isPastLastPage ? (_jsx(TableSkeleton, { columnCount: table.getVisibleLeafColumns().length })) : isEmpty ? (_jsxs(Empty, { "data-state": "empty", children: [_jsxs(EmptyHeader, { children: [_jsx(EmptyTitle, { children: emptyTitle }), emptyDescription && _jsx(EmptyDescription, { children: emptyDescription })] }), emptyAction] })) : (_jsxs(_Fragment, { children: [_jsxs(Table, { "data-state": "success", containerRef: containerRef,
|
|
147
167
|
// With the sticky bar below as the visible scrollbar, hide the
|
|
148
168
|
// container's own. It stays overflow-x-auto and fully
|
|
149
169
|
// scrollable (wheel, trackpad, drag, and the sync effect); it
|
|
@@ -7,8 +7,15 @@ export type TableView<F extends string, M extends string> = {
|
|
|
7
7
|
filters?: Partial<Record<F, string>>;
|
|
8
8
|
multiFilters?: Partial<Record<M, string[]>>;
|
|
9
9
|
sort?: Sort;
|
|
10
|
+
/**
|
|
11
|
+
* The ids of the columns this view shows, for DataTable's
|
|
12
|
+
* `visibleColumns`. Leave it out to show the table's default columns.
|
|
13
|
+
* Applies only while the view is active (see activeViewOf).
|
|
14
|
+
*/
|
|
15
|
+
columns?: readonly string[];
|
|
10
16
|
};
|
|
11
17
|
export declare function serializeSort(next: Sort): string | null;
|
|
12
18
|
export declare function applyUrlChanges(previous: URLSearchParams, changes: UrlChanges): URLSearchParams;
|
|
13
19
|
export declare function viewToUrlChanges<F extends string, M extends string>(filterNames: readonly F[], multiFilterNames: readonly M[], view: TableView<F, M>): UrlChanges;
|
|
20
|
+
export declare function activeViewOf<F extends string, M extends string, V extends string>(views: Readonly<Record<V, TableView<F, M>>>, filters: Readonly<Record<F, string>>, multiFilters: Readonly<Record<M, readonly string[]>>): V | null;
|
|
14
21
|
export {};
|
|
@@ -30,3 +30,17 @@ export function viewToUrlChanges(filterNames, multiFilterNames, view) {
|
|
|
30
30
|
page: null,
|
|
31
31
|
};
|
|
32
32
|
}
|
|
33
|
+
// Which of `views` the current filters are: the first whose filters and
|
|
34
|
+
// multi filters equal the URL's exactly (multi filters in any order), or
|
|
35
|
+
// null. A view is active only while it is still "this combination and
|
|
36
|
+
// nothing else", so adding or changing any filter returns the table to its
|
|
37
|
+
// default columns: a view's columns are chosen for the records its filters
|
|
38
|
+
// select, and would hide a field those other records fill. Sort and page
|
|
39
|
+
// don't change which records are shown, so they don't count. Decided with
|
|
40
|
+
// the developer on 2026-10-05.
|
|
41
|
+
export function activeViewOf(views, filters, multiFilters) {
|
|
42
|
+
const sameItems = (a, b) => a.length === b.length && [...a].sort().every((item, index) => item === [...b].sort()[index]);
|
|
43
|
+
const matches = (view) => Object.keys(filters).every((name) => filters[name] === (view.filters?.[name] ?? '')) &&
|
|
44
|
+
Object.keys(multiFilters).every((name) => sameItems(multiFilters[name], view.multiFilters?.[name] ?? []));
|
|
45
|
+
return Object.keys(views).find((id) => matches(views[id])) ?? null;
|
|
46
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type { SortingState } from '../components/app/data-table.js';
|
|
2
2
|
import { type TableView } from './table-url-changes.js';
|
|
3
|
-
export declare function useTableUrlState<F extends string, M extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[]): {
|
|
3
|
+
export declare function useTableUrlState<F extends string, M extends string = never, V extends string = never>(filterNames: readonly F[], multiFilterNames?: readonly M[], views?: Readonly<Record<V, TableView<NoInfer<F>, NoInfer<M>>>>): {
|
|
4
4
|
page: number;
|
|
5
5
|
sorting: SortingState;
|
|
6
6
|
filters: Record<F, string>;
|
|
7
7
|
multiFilters: Record<M, string[]>;
|
|
8
|
+
activeView: V | null;
|
|
8
9
|
setPage: (next: number) => void;
|
|
9
10
|
setSorting: (next: SortingState) => void;
|
|
10
11
|
setFilter: (name: F, value: string) => void;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useSearchParams } from 'react-router';
|
|
2
|
-
import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-changes.js';
|
|
2
|
+
import { activeViewOf, applyUrlChanges, serializeSort, viewToUrlChanges, } from './table-url-changes.js';
|
|
3
3
|
// A table's page, sort and filters, kept in the URL query string
|
|
4
4
|
// (`?page=2&sort=name:asc&search=mouse&tags=fragile&tags=bulky`) instead of
|
|
5
5
|
// component state, so they survive going to the edit form and back, a
|
|
@@ -10,14 +10,20 @@ import { applyUrlChanges, serializeSort, viewToUrlChanges } from './table-url-ch
|
|
|
10
10
|
// (multi-choice fields) are string arrays, repeated in the URL the same way
|
|
11
11
|
// the API takes them. Changing the sort or any filter returns to page 1.
|
|
12
12
|
//
|
|
13
|
+
// `views` are the table's saved views, by id. `activeView` is the id of the
|
|
14
|
+
// one the current filters are, or null (activeViewOf() says when a view
|
|
15
|
+
// counts as active); pass that view's `columns` to DataTable. A view is
|
|
16
|
+
// pressed with applyView().
|
|
17
|
+
//
|
|
13
18
|
// Every setter is one update(), and must stay one: see setFilters.
|
|
14
|
-
export function useTableUrlState(filterNames, multiFilterNames = []) {
|
|
19
|
+
export function useTableUrlState(filterNames, multiFilterNames = [], views = {}) {
|
|
15
20
|
const [params, setParams] = useSearchParams();
|
|
16
21
|
const page = Math.max(1, Math.floor(Number(params.get('page'))) || 1);
|
|
17
22
|
const [sortField, sortDir] = (params.get('sort') ?? '').split(':');
|
|
18
23
|
const sorting = sortField ? [{ id: sortField, desc: sortDir === 'desc' }] : [];
|
|
19
24
|
const filters = Object.fromEntries(filterNames.map((name) => [name, params.get(name) ?? '']));
|
|
20
25
|
const multiFilters = Object.fromEntries(multiFilterNames.map((name) => [name, params.getAll(name)]));
|
|
26
|
+
const activeView = activeViewOf(views, filters, multiFilters);
|
|
21
27
|
function update(changes) {
|
|
22
28
|
setParams((previous) => applyUrlChanges(previous, changes), { replace: true });
|
|
23
29
|
}
|
|
@@ -26,6 +32,7 @@ export function useTableUrlState(filterNames, multiFilterNames = []) {
|
|
|
26
32
|
sorting,
|
|
27
33
|
filters,
|
|
28
34
|
multiFilters,
|
|
35
|
+
activeView,
|
|
29
36
|
setPage: (next) => update({ page: next > 1 ? String(next) : null }),
|
|
30
37
|
setSorting: (next) => update({ sort: serializeSort(next), page: null }),
|
|
31
38
|
setFilter: (name, value) => update({ [name]: value, page: null }),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tristan2828/ui-foundation",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
4
4
|
"description": "Shared UI foundation for contract-first, database-backed React apps: shadcn primitives, DataTable/EntityForm composites, auth, the gateway error seam, design tokens, lint rules and the AI playbook.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|