@terpjs/react-core 0.1.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/README.md +190 -0
- package/package.json +44 -0
- package/src/AppShell.test.tsx +152 -0
- package/src/AppShell.tsx +554 -0
- package/src/Authorized.test.tsx +60 -0
- package/src/Authorized.tsx +21 -0
- package/src/Breadcrumbs.test.tsx +45 -0
- package/src/Breadcrumbs.tsx +110 -0
- package/src/ConfirmDialog.tsx +170 -0
- package/src/DetailPage.tsx +28 -0
- package/src/EmptyState.tsx +74 -0
- package/src/ErrorState.tsx +108 -0
- package/src/Field.test.tsx +53 -0
- package/src/Field.tsx +51 -0
- package/src/HubPage.test.tsx +108 -0
- package/src/HubPage.tsx +204 -0
- package/src/LoadingState.test.tsx +40 -0
- package/src/LoadingState.tsx +96 -0
- package/src/LoginView.test.tsx +57 -0
- package/src/LoginView.tsx +203 -0
- package/src/ModuleNav.test.tsx +96 -0
- package/src/ModuleNav.tsx +88 -0
- package/src/OverviewPage.tsx +26 -0
- package/src/Page.test.tsx +147 -0
- package/src/Page.tsx +158 -0
- package/src/PageActions.test.tsx +104 -0
- package/src/PageActions.tsx +72 -0
- package/src/ProfileView.test.tsx +112 -0
- package/src/ProfileView.tsx +84 -0
- package/src/RequireAuth.test.tsx +89 -0
- package/src/RequireAuth.tsx +22 -0
- package/src/ResourceList.test.tsx +176 -0
- package/src/ResourceList.tsx +123 -0
- package/src/TerpProvider.tsx +320 -0
- package/src/UserMenu.test.tsx +166 -0
- package/src/UserMenu.tsx +125 -0
- package/src/admin/AdminHub.tsx +108 -0
- package/src/admin/AuditLogAdmin.tsx +116 -0
- package/src/admin/GroupCreate.tsx +90 -0
- package/src/admin/GroupDetail.tsx +446 -0
- package/src/admin/GroupsAdmin.tsx +109 -0
- package/src/admin/UserCreate.tsx +115 -0
- package/src/admin/UserDetail.tsx +228 -0
- package/src/admin/UsersAdmin.tsx +111 -0
- package/src/admin/admin.test.tsx +537 -0
- package/src/admin/crumbs.tsx +14 -0
- package/src/admin/module.tsx +51 -0
- package/src/admin/roles.ts +19 -0
- package/src/bootstrap.test.tsx +67 -0
- package/src/bootstrap.tsx +270 -0
- package/src/capabilities.test.ts +24 -0
- package/src/capabilities.ts +26 -0
- package/src/createAuthClient.test.ts +176 -0
- package/src/createAuthClient.ts +105 -0
- package/src/dataview/DataView.test.tsx +392 -0
- package/src/dataview/DataView.tsx +467 -0
- package/src/dataview/DataViewCardList.tsx +189 -0
- package/src/dataview/DataViewColumnSettings.tsx +118 -0
- package/src/dataview/DataViewExpandableRow.tsx +67 -0
- package/src/dataview/DataViewPagination.tsx +113 -0
- package/src/dataview/DataViewRowActions.tsx +131 -0
- package/src/dataview/DataViewTable.tsx +359 -0
- package/src/dataview/DataViewToolbar.tsx +260 -0
- package/src/dataview/README.md +138 -0
- package/src/dataview/glyphs.tsx +175 -0
- package/src/dataview/hooks/hooks.test.tsx +240 -0
- package/src/dataview/hooks/useDataViewQuery.ts +72 -0
- package/src/dataview/hooks/useDataViewState.ts +310 -0
- package/src/dataview/hooks/useServerDataView.ts +154 -0
- package/src/dataview/hooks/useViewSearch.ts +68 -0
- package/src/dataview/index.ts +62 -0
- package/src/dataview/internal.tsx +96 -0
- package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
- package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
- package/src/dataview/repositories/repositories.test.ts +158 -0
- package/src/dataview/repositories/viewState.test.ts +90 -0
- package/src/dataview/repositories/viewState.ts +128 -0
- package/src/dataview/types.ts +249 -0
- package/src/errorMessages.test.tsx +83 -0
- package/src/errorMessages.tsx +79 -0
- package/src/feedback.test.tsx +167 -0
- package/src/files.test.tsx +142 -0
- package/src/files.tsx +174 -0
- package/src/icons.test.tsx +46 -0
- package/src/icons.tsx +533 -0
- package/src/index.ts +155 -0
- package/src/layout.test.tsx +72 -0
- package/src/layout.tsx +90 -0
- package/src/layoutContract.test.tsx +179 -0
- package/src/layoutContract.ts +137 -0
- package/src/locale.test.tsx +97 -0
- package/src/locale.tsx +246 -0
- package/src/nav.test.ts +21 -0
- package/src/nav.ts +13 -0
- package/src/pageMarker.ts +15 -0
- package/src/raw.d.ts +7 -0
- package/src/realtime-hook.test.tsx +226 -0
- package/src/realtime.test.ts +44 -0
- package/src/realtime.ts +307 -0
- package/src/refresh-session.test.tsx +114 -0
- package/src/revocation.test.tsx +81 -0
- package/src/router.test.tsx +307 -0
- package/src/router.tsx +222 -0
- package/src/sso.test.tsx +128 -0
- package/src/sso.ts +142 -0
- package/src/ssr.test.tsx +45 -0
- package/src/styles.test.ts +21 -0
- package/src/styles.ts +302 -0
- package/src/theme.test.tsx +74 -0
- package/src/theme.tsx +143 -0
- package/src/toast.test.tsx +94 -0
- package/src/toast.tsx +214 -0
- package/src/tokens.guard.test.ts +51 -0
- package/src/ui/Alert.test.tsx +19 -0
- package/src/ui/Alert.tsx +115 -0
- package/src/ui/Badge.test.tsx +14 -0
- package/src/ui/Badge.tsx +48 -0
- package/src/ui/Button.test.tsx +36 -0
- package/src/ui/Button.tsx +95 -0
- package/src/ui/Card.test.tsx +40 -0
- package/src/ui/Card.tsx +92 -0
- package/src/ui/Checkbox.test.tsx +17 -0
- package/src/ui/Checkbox.tsx +51 -0
- package/src/ui/Combobox.test.tsx +58 -0
- package/src/ui/Combobox.tsx +313 -0
- package/src/ui/DatePicker.test.tsx +60 -0
- package/src/ui/DatePicker.tsx +421 -0
- package/src/ui/Input.tsx +30 -0
- package/src/ui/Markdown.test.tsx +32 -0
- package/src/ui/Markdown.tsx +213 -0
- package/src/ui/Menu.test.tsx +85 -0
- package/src/ui/Menu.tsx +216 -0
- package/src/ui/Popover.tsx +218 -0
- package/src/ui/Radio.test.tsx +29 -0
- package/src/ui/Radio.tsx +127 -0
- package/src/ui/Select.tsx +40 -0
- package/src/ui/Switch.test.tsx +17 -0
- package/src/ui/Switch.tsx +53 -0
- package/src/ui/Tabs.test.tsx +29 -0
- package/src/ui/Tabs.tsx +128 -0
- package/src/ui/Textarea.tsx +27 -0
- package/src/ui/Tooltip.test.tsx +28 -0
- package/src/ui/Tooltip.tsx +67 -0
- package/src/ui/controlStyles.ts +9 -0
- package/src/uiText.test.tsx +93 -0
- package/src/uiText.tsx +342 -0
- package/src/unwrap.test.ts +67 -0
- package/src/unwrap.ts +101 -0
- package/src/useResource.test.tsx +118 -0
- package/src/useResource.ts +110 -0
- package/src/useTerpClient.test.ts +35 -0
- package/tsconfig.json +17 -0
- package/vite.config.ts +14 -0
- package/vitest.setup.ts +58 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# DataView
|
|
2
|
+
|
|
3
|
+
The single sanctioned surface for rendering data collections in a Terp app: a
|
|
4
|
+
repository-driven, token-styled table/card view with search, sorting, pagination,
|
|
5
|
+
column management (show/hide, reorder, resize), selection with batch actions, per-row
|
|
6
|
+
actions, expandable rows, a responsive card layout and persisted view preferences.
|
|
7
|
+
|
|
8
|
+
`DataView` never fetches, never touches `localStorage`, and never knows whether data
|
|
9
|
+
is client-side or server-side. All data access goes through a **data repository** and
|
|
10
|
+
all preference persistence through a **view-state repository** — adding a new data
|
|
11
|
+
source or preference store never requires modifying any component file (dependency
|
|
12
|
+
inversion / open-closed).
|
|
13
|
+
|
|
14
|
+
`variant="embedded"` removes pagination and view controls. When its repository
|
|
15
|
+
is non-searchable and no selection, filter, or custom controls exist, DataView
|
|
16
|
+
also omits the toolbar band entirely so related empty collections add no blank
|
|
17
|
+
chrome.
|
|
18
|
+
|
|
19
|
+
## Quick start (client-side data)
|
|
20
|
+
|
|
21
|
+
```tsx
|
|
22
|
+
import {
|
|
23
|
+
DataView,
|
|
24
|
+
InMemoryDataViewRepository,
|
|
25
|
+
LocalStorageViewStateRepository,
|
|
26
|
+
} from "@terpjs/react-core";
|
|
27
|
+
import type { DataViewColumn } from "@terpjs/react-core";
|
|
28
|
+
|
|
29
|
+
interface Ticket { id: string; title: string; status: string; created: string }
|
|
30
|
+
|
|
31
|
+
const columns: DataViewColumn<Ticket>[] = [
|
|
32
|
+
{ id: "title", header: "Title", accessor: (t) => t.title, meta: { mobileSlot: "title" } },
|
|
33
|
+
{ id: "status", header: "Status", accessor: (t) => t.status, meta: { mobileSlot: "status" } },
|
|
34
|
+
{ id: "created", header: "Created", accessor: (t) => t.created, meta: { mobileSlot: "date", width: 120 } },
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
const repository = new InMemoryDataViewRepository(tickets, {
|
|
38
|
+
getRowId: (t) => t.id,
|
|
39
|
+
getValue: (t, col) => t[col as keyof Ticket],
|
|
40
|
+
searchFields: ["title", "status"],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
<DataView<Ticket>
|
|
44
|
+
viewId="tickets.list" // stable key for persisted preferences
|
|
45
|
+
repository={repository}
|
|
46
|
+
viewStateRepository={new LocalStorageViewStateRepository()}
|
|
47
|
+
columns={columns}
|
|
48
|
+
getRowLabel={(t) => t.title} // required with onRowClick (a11y name)
|
|
49
|
+
onRowClick={(t) => navigate(t.id)}
|
|
50
|
+
enableSelection
|
|
51
|
+
batchActions={[{ label: "Archive", onClick: archive, onSelectAll: archiveAll, inline: true }]}
|
|
52
|
+
rowActions={(t) => [
|
|
53
|
+
{ label: "Delete", variant: "destructive", onClick: remove, disabled: (t) => t.status === "closed" },
|
|
54
|
+
]}
|
|
55
|
+
searchDebounceMs={300}
|
|
56
|
+
pageSizeOptions={[10, 25, 50, 100]}
|
|
57
|
+
renderExpanded={(t) => <TicketPreview ticket={t} />}
|
|
58
|
+
/>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Server-side data
|
|
62
|
+
|
|
63
|
+
Server-side views keep sorting/filter/pagination in the URL via `useServerDataView`
|
|
64
|
+
(deep-linkable, survives reloads); the repository maps the emitted `DataViewQuery` to
|
|
65
|
+
API parameters through an injectable request adapter:
|
|
66
|
+
|
|
67
|
+
```tsx
|
|
68
|
+
import { DataView, HttpDataViewRepository, useServerDataView, unwrap } from "@terpjs/react-core";
|
|
69
|
+
|
|
70
|
+
const repository = new HttpDataViewRepository<NoteRead>({
|
|
71
|
+
getRowId: (n) => n.id,
|
|
72
|
+
request: async ({ skip, limit }, signal) => {
|
|
73
|
+
const page = unwrap(await client.GET("/api/v1/notes/", { params: { query: { skip, limit } }, signal }));
|
|
74
|
+
return { items: page.items, total: page.total };
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
function NotesPage() {
|
|
79
|
+
const serverQuery = useServerDataView({ initialPageSize: 25 });
|
|
80
|
+
return <DataView repository={repository} columns={columns} serverQuery={serverQuery} />;
|
|
81
|
+
}
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## The repository interfaces
|
|
85
|
+
|
|
86
|
+
### `DataViewRepository<T>` (data access)
|
|
87
|
+
|
|
88
|
+
| Member | Meaning |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `query(q, signal?)` | Return one `{ rows, totalCount }` page for a `DataViewQuery` (pagination, sorting, filters, search, searchBroadened). |
|
|
91
|
+
| `getRowId(row)` | Stable row identity — selection/expansion survive re-sorts and refetches. |
|
|
92
|
+
| `capabilities.serverSide` | `true` → the repo does sorting/filtering/paging per query; `false` → it owns a full client-side data set. |
|
|
93
|
+
| `capabilities.search` | Whether the toolbar search box renders. |
|
|
94
|
+
| `capabilities.searchScope` | Whether the broadened "search everything" toggle is supported. |
|
|
95
|
+
| `getFacetedValues?(columnId)` | Optional: distinct values of a column (client-side facets). |
|
|
96
|
+
|
|
97
|
+
Implementations shipped: `InMemoryDataViewRepository` (wraps a plain array;
|
|
98
|
+
filter/search/sort/page client-side) and `HttpDataViewRepository` (maps the query to
|
|
99
|
+
`skip = pageIndex * pageSize`, `limit = pageSize`, sort/filter/search params and
|
|
100
|
+
delegates the transport to an injectable adapter).
|
|
101
|
+
|
|
102
|
+
### `ViewStateRepository` (persisted preferences)
|
|
103
|
+
|
|
104
|
+
`load(viewId)` / `save(viewId, state)` for everything the user customises: column
|
|
105
|
+
visibility, order, resized widths, and — for client-side views — sorting, filters and
|
|
106
|
+
search. Implementations shipped: `LocalStorageViewStateRepository` (schema-validated,
|
|
107
|
+
versioned envelope; corrupt data falls back to defaults) and
|
|
108
|
+
`InMemoryViewStateRepository` (tests, or views without a `viewId`).
|
|
109
|
+
|
|
110
|
+
## Behaviour notes
|
|
111
|
+
|
|
112
|
+
- **System columns** are auto-injected in a fixed order — expand toggle, selection
|
|
113
|
+
checkbox, user columns, row-actions (sr-only header) — pinned to narrow widths and
|
|
114
|
+
never hideable/reorderable/resizable.
|
|
115
|
+
- **Column resizing**: drag the header handle; widths update live with no persistence
|
|
116
|
+
writes per pointermove and are persisted once, on pointer-up. Width precedence:
|
|
117
|
+
pinned system columns → user-resized → static `meta.width` hint → auto.
|
|
118
|
+
- **Select-all-across-pages**: after selecting the whole page the toolbar offers
|
|
119
|
+
"Select all N results"; batch actions then invoke their `onSelectAll` variant. The
|
|
120
|
+
mode resets whenever the page selection is broken.
|
|
121
|
+
- **Responsive**: auto-switches to the stacked card layout at the mobile breakpoint
|
|
122
|
+
until the user chooses a layout explicitly (manual choice wins). Cards are composed
|
|
123
|
+
from `meta.mobileSlot` (`title` / `subtitle` / `status` / `date`), with
|
|
124
|
+
`renderCard(row)` as a full escape hatch; selection, actions and expansion keep
|
|
125
|
+
working in card view.
|
|
126
|
+
- **Variants**: `variant="embedded"` renders a plain compact view (no view toggle, no
|
|
127
|
+
page-size selector, no pagination footer, all rows) for panels/detail sections.
|
|
128
|
+
- **i18n**: no hard-coded user-facing strings — every label is a `UiText` routed
|
|
129
|
+
through the app's `UiTextProvider` resolver, with defaults overridable per instance
|
|
130
|
+
via the `strings` prop.
|
|
131
|
+
|
|
132
|
+
## Files
|
|
133
|
+
|
|
134
|
+
- `DataView.tsx` — composition only
|
|
135
|
+
- `DataViewToolbar` / `DataViewPagination` / `DataViewColumnSettings` /
|
|
136
|
+
`DataViewRowActions` / `DataViewExpandableRow` / `DataViewCardList` / `DataViewTable`
|
|
137
|
+
- `repositories/` — the interfaces' implementations
|
|
138
|
+
- `hooks/` — `useDataViewState`, `useServerDataView`, `useViewSearch`, `useDataViewQuery`
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// Decorative inline SVG glyphs for the DataView family (react-core ships no icon
|
|
4
|
+
// library). All are aria-hidden; interactive wrappers carry the accessible names.
|
|
5
|
+
|
|
6
|
+
interface GlyphProps {
|
|
7
|
+
size?: number;
|
|
8
|
+
style?: CSSProperties;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function glyphProps({ size = 16, style }: GlyphProps) {
|
|
12
|
+
return {
|
|
13
|
+
width: size,
|
|
14
|
+
height: size,
|
|
15
|
+
viewBox: "0 0 24 24",
|
|
16
|
+
fill: "none",
|
|
17
|
+
stroke: "currentColor",
|
|
18
|
+
strokeWidth: 2,
|
|
19
|
+
strokeLinecap: "round",
|
|
20
|
+
strokeLinejoin: "round",
|
|
21
|
+
"aria-hidden": true,
|
|
22
|
+
focusable: false,
|
|
23
|
+
style,
|
|
24
|
+
} as const;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function ChevronDownGlyph(props: GlyphProps) {
|
|
28
|
+
return (
|
|
29
|
+
<svg {...glyphProps(props)}>
|
|
30
|
+
<polyline points="6 9 12 15 18 9" />
|
|
31
|
+
</svg>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ChevronRightGlyph(props: GlyphProps) {
|
|
36
|
+
return (
|
|
37
|
+
<svg {...glyphProps(props)}>
|
|
38
|
+
<polyline points="9 6 15 12 9 18" />
|
|
39
|
+
</svg>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function SortAscGlyph(props: GlyphProps) {
|
|
44
|
+
return (
|
|
45
|
+
<svg {...glyphProps(props)}>
|
|
46
|
+
<path d="M12 19V5" />
|
|
47
|
+
<polyline points="5 12 12 5 19 12" />
|
|
48
|
+
</svg>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function SortDescGlyph(props: GlyphProps) {
|
|
53
|
+
return (
|
|
54
|
+
<svg {...glyphProps(props)}>
|
|
55
|
+
<path d="M12 5v14" />
|
|
56
|
+
<polyline points="19 12 12 19 5 12" />
|
|
57
|
+
</svg>
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function SortNoneGlyph(props: GlyphProps) {
|
|
62
|
+
return (
|
|
63
|
+
<svg {...glyphProps(props)}>
|
|
64
|
+
<polyline points="8 9 12 5 16 9" />
|
|
65
|
+
<polyline points="8 15 12 19 16 15" />
|
|
66
|
+
</svg>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function SearchGlyph(props: GlyphProps) {
|
|
71
|
+
return (
|
|
72
|
+
<svg {...glyphProps(props)}>
|
|
73
|
+
<circle cx="11" cy="11" r="7" />
|
|
74
|
+
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
|
75
|
+
</svg>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function CloseGlyph(props: GlyphProps) {
|
|
80
|
+
return (
|
|
81
|
+
<svg {...glyphProps(props)}>
|
|
82
|
+
<line x1="18" y1="6" x2="6" y2="18" />
|
|
83
|
+
<line x1="6" y1="6" x2="18" y2="18" />
|
|
84
|
+
</svg>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function EllipsisGlyph(props: GlyphProps) {
|
|
89
|
+
return (
|
|
90
|
+
<svg {...glyphProps(props)}>
|
|
91
|
+
<circle cx="5" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
|
92
|
+
<circle cx="12" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
|
93
|
+
<circle cx="19" cy="12" r="1.5" fill="currentColor" stroke="none" />
|
|
94
|
+
</svg>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function ColumnsGlyph(props: GlyphProps) {
|
|
99
|
+
return (
|
|
100
|
+
<svg {...glyphProps(props)}>
|
|
101
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
102
|
+
<line x1="9" y1="4" x2="9" y2="20" />
|
|
103
|
+
<line x1="15" y1="4" x2="15" y2="20" />
|
|
104
|
+
</svg>
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function TableGlyph(props: GlyphProps) {
|
|
109
|
+
return (
|
|
110
|
+
<svg {...glyphProps(props)}>
|
|
111
|
+
<rect x="3" y="4" width="18" height="16" rx="2" />
|
|
112
|
+
<line x1="3" y1="10" x2="21" y2="10" />
|
|
113
|
+
<line x1="3" y1="15" x2="21" y2="15" />
|
|
114
|
+
</svg>
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function CardsGlyph(props: GlyphProps) {
|
|
119
|
+
return (
|
|
120
|
+
<svg {...glyphProps(props)}>
|
|
121
|
+
<rect x="3" y="4" width="18" height="7" rx="2" />
|
|
122
|
+
<rect x="3" y="14" width="18" height="7" rx="2" />
|
|
123
|
+
</svg>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function ArrowUpGlyph(props: GlyphProps) {
|
|
128
|
+
return (
|
|
129
|
+
<svg {...glyphProps(props)}>
|
|
130
|
+
<polyline points="6 14 12 8 18 14" />
|
|
131
|
+
</svg>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function ArrowDownGlyph(props: GlyphProps) {
|
|
136
|
+
return (
|
|
137
|
+
<svg {...glyphProps(props)}>
|
|
138
|
+
<polyline points="6 10 12 16 18 10" />
|
|
139
|
+
</svg>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function PageFirstGlyph(props: GlyphProps) {
|
|
144
|
+
return (
|
|
145
|
+
<svg {...glyphProps(props)}>
|
|
146
|
+
<polyline points="11 17 6 12 11 7" />
|
|
147
|
+
<polyline points="18 17 13 12 18 7" />
|
|
148
|
+
</svg>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function PagePrevGlyph(props: GlyphProps) {
|
|
153
|
+
return (
|
|
154
|
+
<svg {...glyphProps(props)}>
|
|
155
|
+
<polyline points="15 18 9 12 15 6" />
|
|
156
|
+
</svg>
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function PageNextGlyph(props: GlyphProps) {
|
|
161
|
+
return (
|
|
162
|
+
<svg {...glyphProps(props)}>
|
|
163
|
+
<polyline points="9 18 15 12 9 6" />
|
|
164
|
+
</svg>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function PageLastGlyph(props: GlyphProps) {
|
|
169
|
+
return (
|
|
170
|
+
<svg {...glyphProps(props)}>
|
|
171
|
+
<polyline points="13 17 18 12 13 7" />
|
|
172
|
+
<polyline points="6 17 11 12 6 7" />
|
|
173
|
+
</svg>
|
|
174
|
+
);
|
|
175
|
+
}
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, renderHook, act } from "@testing-library/react";
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
|
|
5
|
+
import { useViewSearch } from "./useViewSearch";
|
|
6
|
+
import { useDataViewState } from "./useDataViewState";
|
|
7
|
+
import { useServerDataView } from "./useServerDataView";
|
|
8
|
+
import { InMemoryViewStateRepository } from "../repositories/viewState";
|
|
9
|
+
import type { DataViewColumn } from "../types";
|
|
10
|
+
|
|
11
|
+
afterEach(() => {
|
|
12
|
+
cleanup();
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
window.history.replaceState(null, "", "/");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("useViewSearch", () => {
|
|
18
|
+
it("emits immediately without a debounce", () => {
|
|
19
|
+
const onChange = vi.fn();
|
|
20
|
+
const { result } = renderHook(() => useViewSearch("", onChange));
|
|
21
|
+
act(() => result.current.setInputValue("a"));
|
|
22
|
+
expect(onChange).toHaveBeenCalledWith("a");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("debounces emissions while keeping the input immediate", () => {
|
|
26
|
+
vi.useFakeTimers();
|
|
27
|
+
const onChange = vi.fn();
|
|
28
|
+
const { result } = renderHook(() => useViewSearch("", onChange, 300));
|
|
29
|
+
|
|
30
|
+
act(() => result.current.setInputValue("p"));
|
|
31
|
+
act(() => result.current.setInputValue("pr"));
|
|
32
|
+
expect(result.current.inputValue).toBe("pr");
|
|
33
|
+
expect(onChange).not.toHaveBeenCalled();
|
|
34
|
+
|
|
35
|
+
act(() => vi.advanceTimersByTime(300));
|
|
36
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
37
|
+
expect(onChange).toHaveBeenCalledWith("pr");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it("clear() cancels the pending debounce and emits \"\" immediately", () => {
|
|
41
|
+
vi.useFakeTimers();
|
|
42
|
+
const onChange = vi.fn();
|
|
43
|
+
const { result } = renderHook(() => useViewSearch("", onChange, 300));
|
|
44
|
+
act(() => result.current.setInputValue("x"));
|
|
45
|
+
act(() => result.current.clear());
|
|
46
|
+
expect(onChange).toHaveBeenCalledWith("");
|
|
47
|
+
act(() => vi.advanceTimersByTime(500));
|
|
48
|
+
expect(onChange).toHaveBeenCalledTimes(1);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("syncs external value changes back into the input", () => {
|
|
52
|
+
const { result, rerender } = renderHook(
|
|
53
|
+
({ value }: { value: string }) => useViewSearch(value, () => undefined),
|
|
54
|
+
{ initialProps: { value: "" } },
|
|
55
|
+
);
|
|
56
|
+
rerender({ value: "reset" });
|
|
57
|
+
expect(result.current.inputValue).toBe("reset");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("an external reset cancels a pending debounced emit", () => {
|
|
61
|
+
vi.useFakeTimers();
|
|
62
|
+
const onChange = vi.fn();
|
|
63
|
+
const { result, rerender } = renderHook(
|
|
64
|
+
({ value }: { value: string }) => useViewSearch(value, onChange, 300),
|
|
65
|
+
{ initialProps: { value: "x" } },
|
|
66
|
+
);
|
|
67
|
+
act(() => result.current.setInputValue("xy"));
|
|
68
|
+
rerender({ value: "" }); // e.g. a caller-driven "clear filters"
|
|
69
|
+
act(() => vi.advanceTimersByTime(300));
|
|
70
|
+
expect(onChange).not.toHaveBeenCalled(); // "xy" never resurrects the cleared search
|
|
71
|
+
expect(result.current.inputValue).toBe("");
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
interface Row {
|
|
76
|
+
id: string;
|
|
77
|
+
title: string;
|
|
78
|
+
status: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const COLUMNS: DataViewColumn<Row>[] = [
|
|
82
|
+
{ id: "title", header: "Title" },
|
|
83
|
+
{ id: "status", header: "Status" },
|
|
84
|
+
{ id: "created", header: "Created" },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
function renderState(viewStateRepository: InMemoryViewStateRepository, viewId = "v") {
|
|
88
|
+
return renderHook(() =>
|
|
89
|
+
useDataViewState<Row>({
|
|
90
|
+
viewId,
|
|
91
|
+
columns: COLUMNS,
|
|
92
|
+
viewStateRepository,
|
|
93
|
+
serverSide: false,
|
|
94
|
+
initialPageSize: 10,
|
|
95
|
+
}),
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe("useDataViewState", () => {
|
|
100
|
+
it("toggles sort asc → desc → none", () => {
|
|
101
|
+
const { result } = renderState(new InMemoryViewStateRepository());
|
|
102
|
+
act(() => result.current.toggleSort("title"));
|
|
103
|
+
expect(result.current.sorting).toEqual([{ id: "title", desc: false }]);
|
|
104
|
+
act(() => result.current.toggleSort("title"));
|
|
105
|
+
expect(result.current.sorting).toEqual([{ id: "title", desc: true }]);
|
|
106
|
+
act(() => result.current.toggleSort("title"));
|
|
107
|
+
expect(result.current.sorting).toEqual([]);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("persists column sizing once per commit and restores it", () => {
|
|
111
|
+
const store = new InMemoryViewStateRepository();
|
|
112
|
+
const save = vi.spyOn(store, "save");
|
|
113
|
+
const first = renderState(store);
|
|
114
|
+
act(() => first.result.current.commitColumnSizing({ title: 240, status: 120 }));
|
|
115
|
+
expect(save).toHaveBeenCalledTimes(1);
|
|
116
|
+
expect(first.result.current.columnSizing).toEqual({ title: 240, status: 120 });
|
|
117
|
+
|
|
118
|
+
const second = renderState(store);
|
|
119
|
+
expect(second.result.current.columnSizing).toEqual({ title: 240, status: 120 });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("persists visibility and ordering, and restores effective column order", () => {
|
|
123
|
+
const store = new InMemoryViewStateRepository();
|
|
124
|
+
const first = renderState(store);
|
|
125
|
+
act(() => first.result.current.setColumnVisible("status", false));
|
|
126
|
+
act(() => first.result.current.moveColumn("created", -1));
|
|
127
|
+
|
|
128
|
+
const second = renderState(store);
|
|
129
|
+
expect(second.result.current.orderedColumns.map((c) => c.id)).toEqual([
|
|
130
|
+
"title",
|
|
131
|
+
"created",
|
|
132
|
+
"status",
|
|
133
|
+
]);
|
|
134
|
+
expect(second.result.current.visibleColumns.map((c) => c.id)).toEqual(["title", "created"]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("clamps reordering at the edges", () => {
|
|
138
|
+
const { result } = renderState(new InMemoryViewStateRepository());
|
|
139
|
+
act(() => result.current.moveColumn("title", -1));
|
|
140
|
+
expect(result.current.orderedColumns.map((c) => c.id)).toEqual(["title", "status", "created"]);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("resets the page index when search or filters change", () => {
|
|
144
|
+
const { result } = renderState(new InMemoryViewStateRepository());
|
|
145
|
+
act(() => result.current.setPagination({ pageIndex: 3, pageSize: 10 }));
|
|
146
|
+
act(() => result.current.setSearch("printer"));
|
|
147
|
+
expect(result.current.pagination.pageIndex).toBe(0);
|
|
148
|
+
|
|
149
|
+
act(() => result.current.setPagination({ pageIndex: 2, pageSize: 10 }));
|
|
150
|
+
act(() => result.current.setFilter("status", "open"));
|
|
151
|
+
expect(result.current.pagination.pageIndex).toBe(0);
|
|
152
|
+
expect(result.current.filters).toEqual([{ id: "status", value: "open" }]);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("persists search/sorting/filters for client-side views and restores them", () => {
|
|
156
|
+
const store = new InMemoryViewStateRepository();
|
|
157
|
+
const first = renderState(store);
|
|
158
|
+
act(() => first.result.current.setSearch("vpn"));
|
|
159
|
+
act(() => first.result.current.toggleSort("title"));
|
|
160
|
+
|
|
161
|
+
const second = renderState(store);
|
|
162
|
+
expect(second.result.current.search).toBe("vpn");
|
|
163
|
+
expect(second.result.current.sorting).toEqual([{ id: "title", desc: false }]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("keeps sorting/filters/search out of persistence for server-side views", () => {
|
|
167
|
+
const store = new InMemoryViewStateRepository();
|
|
168
|
+
const save = vi.spyOn(store, "save");
|
|
169
|
+
const { result } = renderHook(() =>
|
|
170
|
+
useDataViewState<Row>({
|
|
171
|
+
viewId: "v",
|
|
172
|
+
columns: COLUMNS,
|
|
173
|
+
viewStateRepository: store,
|
|
174
|
+
serverSide: true,
|
|
175
|
+
initialPageSize: 10,
|
|
176
|
+
}),
|
|
177
|
+
);
|
|
178
|
+
act(() => result.current.toggleSort("title"));
|
|
179
|
+
act(() => result.current.setColumnVisible("status", false));
|
|
180
|
+
const persisted = save.mock.calls.at(-1)?.[1];
|
|
181
|
+
expect(persisted?.sorting).toEqual([]);
|
|
182
|
+
expect(persisted?.search).toBe("");
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("delegates query state to a controlled query when provided", () => {
|
|
186
|
+
const onSortingChange = vi.fn();
|
|
187
|
+
const { result } = renderHook(() =>
|
|
188
|
+
useDataViewState<Row>({
|
|
189
|
+
columns: COLUMNS,
|
|
190
|
+
serverSide: true,
|
|
191
|
+
initialPageSize: 10,
|
|
192
|
+
controlledQuery: {
|
|
193
|
+
sorting: [{ id: "status", desc: true }],
|
|
194
|
+
filters: [],
|
|
195
|
+
search: "",
|
|
196
|
+
pagination: { pageIndex: 4, pageSize: 25 },
|
|
197
|
+
onSortingChange,
|
|
198
|
+
onFiltersChange: vi.fn(),
|
|
199
|
+
onSearchChange: vi.fn(),
|
|
200
|
+
onPaginationChange: vi.fn(),
|
|
201
|
+
},
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
expect(result.current.sorting).toEqual([{ id: "status", desc: true }]);
|
|
205
|
+
expect(result.current.pagination).toEqual({ pageIndex: 4, pageSize: 25 });
|
|
206
|
+
act(() => result.current.toggleSort("status"));
|
|
207
|
+
expect(onSortingChange).toHaveBeenCalledWith([]);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("useServerDataView", () => {
|
|
212
|
+
it("reads its initial state from the URL", () => {
|
|
213
|
+
window.history.replaceState(null, "", "/?page=3&size=25&sort=-title&q=vpn");
|
|
214
|
+
const { result } = renderHook(() => useServerDataView());
|
|
215
|
+
expect(result.current.pagination).toEqual({ pageIndex: 2, pageSize: 25 });
|
|
216
|
+
expect(result.current.sorting).toEqual([{ id: "title", desc: true }]);
|
|
217
|
+
expect(result.current.search).toBe("vpn");
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("writes state changes back into the URL", () => {
|
|
221
|
+
const { result } = renderHook(() => useServerDataView());
|
|
222
|
+
act(() => result.current.onSearchChange("printer"));
|
|
223
|
+
act(() => result.current.onPaginationChange({ pageIndex: 1, pageSize: 10 }));
|
|
224
|
+
const params = new URLSearchParams(window.location.search);
|
|
225
|
+
expect(params.get("q")).toBe("printer");
|
|
226
|
+
expect(params.get("page")).toBe("2");
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it("prefixes parameters so several views share one page", () => {
|
|
230
|
+
const { result } = renderHook(() => useServerDataView({ paramPrefix: "t" }));
|
|
231
|
+
act(() => result.current.onSearchChange("x"));
|
|
232
|
+
expect(new URLSearchParams(window.location.search).get("t.q")).toBe("x");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("ignores a malformed filters parameter", () => {
|
|
236
|
+
window.history.replaceState(null, "", "/?filters=%7Bnot-json");
|
|
237
|
+
const { result } = renderHook(() => useServerDataView());
|
|
238
|
+
expect(result.current.filters).toEqual([]);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { DataViewQuery, DataViewRepository } from "../types";
|
|
4
|
+
|
|
5
|
+
export interface UseDataViewQueryResult<T> {
|
|
6
|
+
rows: T[];
|
|
7
|
+
totalCount: number;
|
|
8
|
+
/** True until the first page for this repository has resolved (skeleton state). */
|
|
9
|
+
isLoading: boolean;
|
|
10
|
+
/** True while any query is in flight (subtle refresh indicator; stale data stays). */
|
|
11
|
+
isFetching: boolean;
|
|
12
|
+
error: unknown;
|
|
13
|
+
/** Re-run the current query. */
|
|
14
|
+
refresh: () => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Runs `repository.query` whenever the query changes, with abort-on-supersede:
|
|
19
|
+
* a newer query cancels the in-flight one, and stale results never land. Data from
|
|
20
|
+
* the previous query stays visible while the next one loads (`isFetching`).
|
|
21
|
+
*/
|
|
22
|
+
export function useDataViewQuery<T>(
|
|
23
|
+
repository: DataViewRepository<T>,
|
|
24
|
+
query: DataViewQuery,
|
|
25
|
+
): UseDataViewQueryResult<T> {
|
|
26
|
+
const [rows, setRows] = useState<T[]>([]);
|
|
27
|
+
const [totalCount, setTotalCount] = useState(0);
|
|
28
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
29
|
+
const [isFetching, setIsFetching] = useState(true);
|
|
30
|
+
const [error, setError] = useState<unknown>(null);
|
|
31
|
+
const [refreshToken, setRefreshToken] = useState(0);
|
|
32
|
+
|
|
33
|
+
// Serialize the query so effect deps are value-based (no refetch loop when the
|
|
34
|
+
// caller rebuilds an identical query object each render).
|
|
35
|
+
const queryKey = JSON.stringify(query);
|
|
36
|
+
const queryRef = useRef(query);
|
|
37
|
+
queryRef.current = query;
|
|
38
|
+
|
|
39
|
+
useEffect(() => {
|
|
40
|
+
const controller = new AbortController();
|
|
41
|
+
let active = true;
|
|
42
|
+
setIsFetching(true);
|
|
43
|
+
repository
|
|
44
|
+
.query(queryRef.current, controller.signal)
|
|
45
|
+
.then((result) => {
|
|
46
|
+
if (!active) {
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
setRows(result.rows);
|
|
50
|
+
setTotalCount(result.totalCount);
|
|
51
|
+
setError(null);
|
|
52
|
+
setIsLoading(false);
|
|
53
|
+
setIsFetching(false);
|
|
54
|
+
})
|
|
55
|
+
.catch((caught: unknown) => {
|
|
56
|
+
if (!active || controller.signal.aborted) {
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
setError(caught);
|
|
60
|
+
setIsLoading(false);
|
|
61
|
+
setIsFetching(false);
|
|
62
|
+
});
|
|
63
|
+
return () => {
|
|
64
|
+
active = false;
|
|
65
|
+
controller.abort();
|
|
66
|
+
};
|
|
67
|
+
}, [repository, queryKey, refreshToken]);
|
|
68
|
+
|
|
69
|
+
const refresh = useCallback(() => setRefreshToken((token) => token + 1), []);
|
|
70
|
+
|
|
71
|
+
return { rows, totalCount, isLoading, isFetching, error, refresh };
|
|
72
|
+
}
|