@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.
Files changed (154) hide show
  1. package/README.md +190 -0
  2. package/package.json +44 -0
  3. package/src/AppShell.test.tsx +152 -0
  4. package/src/AppShell.tsx +554 -0
  5. package/src/Authorized.test.tsx +60 -0
  6. package/src/Authorized.tsx +21 -0
  7. package/src/Breadcrumbs.test.tsx +45 -0
  8. package/src/Breadcrumbs.tsx +110 -0
  9. package/src/ConfirmDialog.tsx +170 -0
  10. package/src/DetailPage.tsx +28 -0
  11. package/src/EmptyState.tsx +74 -0
  12. package/src/ErrorState.tsx +108 -0
  13. package/src/Field.test.tsx +53 -0
  14. package/src/Field.tsx +51 -0
  15. package/src/HubPage.test.tsx +108 -0
  16. package/src/HubPage.tsx +204 -0
  17. package/src/LoadingState.test.tsx +40 -0
  18. package/src/LoadingState.tsx +96 -0
  19. package/src/LoginView.test.tsx +57 -0
  20. package/src/LoginView.tsx +203 -0
  21. package/src/ModuleNav.test.tsx +96 -0
  22. package/src/ModuleNav.tsx +88 -0
  23. package/src/OverviewPage.tsx +26 -0
  24. package/src/Page.test.tsx +147 -0
  25. package/src/Page.tsx +158 -0
  26. package/src/PageActions.test.tsx +104 -0
  27. package/src/PageActions.tsx +72 -0
  28. package/src/ProfileView.test.tsx +112 -0
  29. package/src/ProfileView.tsx +84 -0
  30. package/src/RequireAuth.test.tsx +89 -0
  31. package/src/RequireAuth.tsx +22 -0
  32. package/src/ResourceList.test.tsx +176 -0
  33. package/src/ResourceList.tsx +123 -0
  34. package/src/TerpProvider.tsx +320 -0
  35. package/src/UserMenu.test.tsx +166 -0
  36. package/src/UserMenu.tsx +125 -0
  37. package/src/admin/AdminHub.tsx +108 -0
  38. package/src/admin/AuditLogAdmin.tsx +116 -0
  39. package/src/admin/GroupCreate.tsx +90 -0
  40. package/src/admin/GroupDetail.tsx +446 -0
  41. package/src/admin/GroupsAdmin.tsx +109 -0
  42. package/src/admin/UserCreate.tsx +115 -0
  43. package/src/admin/UserDetail.tsx +228 -0
  44. package/src/admin/UsersAdmin.tsx +111 -0
  45. package/src/admin/admin.test.tsx +537 -0
  46. package/src/admin/crumbs.tsx +14 -0
  47. package/src/admin/module.tsx +51 -0
  48. package/src/admin/roles.ts +19 -0
  49. package/src/bootstrap.test.tsx +67 -0
  50. package/src/bootstrap.tsx +270 -0
  51. package/src/capabilities.test.ts +24 -0
  52. package/src/capabilities.ts +26 -0
  53. package/src/createAuthClient.test.ts +176 -0
  54. package/src/createAuthClient.ts +105 -0
  55. package/src/dataview/DataView.test.tsx +392 -0
  56. package/src/dataview/DataView.tsx +467 -0
  57. package/src/dataview/DataViewCardList.tsx +189 -0
  58. package/src/dataview/DataViewColumnSettings.tsx +118 -0
  59. package/src/dataview/DataViewExpandableRow.tsx +67 -0
  60. package/src/dataview/DataViewPagination.tsx +113 -0
  61. package/src/dataview/DataViewRowActions.tsx +131 -0
  62. package/src/dataview/DataViewTable.tsx +359 -0
  63. package/src/dataview/DataViewToolbar.tsx +260 -0
  64. package/src/dataview/README.md +138 -0
  65. package/src/dataview/glyphs.tsx +175 -0
  66. package/src/dataview/hooks/hooks.test.tsx +240 -0
  67. package/src/dataview/hooks/useDataViewQuery.ts +72 -0
  68. package/src/dataview/hooks/useDataViewState.ts +310 -0
  69. package/src/dataview/hooks/useServerDataView.ts +154 -0
  70. package/src/dataview/hooks/useViewSearch.ts +68 -0
  71. package/src/dataview/index.ts +62 -0
  72. package/src/dataview/internal.tsx +96 -0
  73. package/src/dataview/repositories/HttpDataViewRepository.ts +110 -0
  74. package/src/dataview/repositories/InMemoryDataViewRepository.ts +145 -0
  75. package/src/dataview/repositories/repositories.test.ts +158 -0
  76. package/src/dataview/repositories/viewState.test.ts +90 -0
  77. package/src/dataview/repositories/viewState.ts +128 -0
  78. package/src/dataview/types.ts +249 -0
  79. package/src/errorMessages.test.tsx +83 -0
  80. package/src/errorMessages.tsx +79 -0
  81. package/src/feedback.test.tsx +167 -0
  82. package/src/files.test.tsx +142 -0
  83. package/src/files.tsx +174 -0
  84. package/src/icons.test.tsx +46 -0
  85. package/src/icons.tsx +533 -0
  86. package/src/index.ts +155 -0
  87. package/src/layout.test.tsx +72 -0
  88. package/src/layout.tsx +90 -0
  89. package/src/layoutContract.test.tsx +179 -0
  90. package/src/layoutContract.ts +137 -0
  91. package/src/locale.test.tsx +97 -0
  92. package/src/locale.tsx +246 -0
  93. package/src/nav.test.ts +21 -0
  94. package/src/nav.ts +13 -0
  95. package/src/pageMarker.ts +15 -0
  96. package/src/raw.d.ts +7 -0
  97. package/src/realtime-hook.test.tsx +226 -0
  98. package/src/realtime.test.ts +44 -0
  99. package/src/realtime.ts +307 -0
  100. package/src/refresh-session.test.tsx +114 -0
  101. package/src/revocation.test.tsx +81 -0
  102. package/src/router.test.tsx +307 -0
  103. package/src/router.tsx +222 -0
  104. package/src/sso.test.tsx +128 -0
  105. package/src/sso.ts +142 -0
  106. package/src/ssr.test.tsx +45 -0
  107. package/src/styles.test.ts +21 -0
  108. package/src/styles.ts +302 -0
  109. package/src/theme.test.tsx +74 -0
  110. package/src/theme.tsx +143 -0
  111. package/src/toast.test.tsx +94 -0
  112. package/src/toast.tsx +214 -0
  113. package/src/tokens.guard.test.ts +51 -0
  114. package/src/ui/Alert.test.tsx +19 -0
  115. package/src/ui/Alert.tsx +115 -0
  116. package/src/ui/Badge.test.tsx +14 -0
  117. package/src/ui/Badge.tsx +48 -0
  118. package/src/ui/Button.test.tsx +36 -0
  119. package/src/ui/Button.tsx +95 -0
  120. package/src/ui/Card.test.tsx +40 -0
  121. package/src/ui/Card.tsx +92 -0
  122. package/src/ui/Checkbox.test.tsx +17 -0
  123. package/src/ui/Checkbox.tsx +51 -0
  124. package/src/ui/Combobox.test.tsx +58 -0
  125. package/src/ui/Combobox.tsx +313 -0
  126. package/src/ui/DatePicker.test.tsx +60 -0
  127. package/src/ui/DatePicker.tsx +421 -0
  128. package/src/ui/Input.tsx +30 -0
  129. package/src/ui/Markdown.test.tsx +32 -0
  130. package/src/ui/Markdown.tsx +213 -0
  131. package/src/ui/Menu.test.tsx +85 -0
  132. package/src/ui/Menu.tsx +216 -0
  133. package/src/ui/Popover.tsx +218 -0
  134. package/src/ui/Radio.test.tsx +29 -0
  135. package/src/ui/Radio.tsx +127 -0
  136. package/src/ui/Select.tsx +40 -0
  137. package/src/ui/Switch.test.tsx +17 -0
  138. package/src/ui/Switch.tsx +53 -0
  139. package/src/ui/Tabs.test.tsx +29 -0
  140. package/src/ui/Tabs.tsx +128 -0
  141. package/src/ui/Textarea.tsx +27 -0
  142. package/src/ui/Tooltip.test.tsx +28 -0
  143. package/src/ui/Tooltip.tsx +67 -0
  144. package/src/ui/controlStyles.ts +9 -0
  145. package/src/uiText.test.tsx +93 -0
  146. package/src/uiText.tsx +342 -0
  147. package/src/unwrap.test.ts +67 -0
  148. package/src/unwrap.ts +101 -0
  149. package/src/useResource.test.tsx +118 -0
  150. package/src/useResource.ts +110 -0
  151. package/src/useTerpClient.test.ts +35 -0
  152. package/tsconfig.json +17 -0
  153. package/vite.config.ts +14 -0
  154. 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
+ }