@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,310 @@
1
+ import { useCallback, useMemo, useRef, useState } from "react";
2
+
3
+ import type {
4
+ DataViewColumn,
5
+ DataViewState,
6
+ ViewStateRepository,
7
+ } from "../types";
8
+ import { emptyDataViewState } from "../types";
9
+
10
+ export type DataViewSorting = { id: string; desc: boolean }[];
11
+ export type DataViewFilters = { id: string; value: unknown }[];
12
+ export interface DataViewPaginationState {
13
+ pageIndex: number;
14
+ pageSize: number;
15
+ }
16
+
17
+ /**
18
+ * Externally controlled query state (server-side views): {@link useServerDataView}
19
+ * produces this shape from the URL; passing it makes the hook delegate sorting /
20
+ * filters / search / pagination instead of owning them.
21
+ */
22
+ export interface DataViewControlledQuery {
23
+ sorting: DataViewSorting;
24
+ filters: DataViewFilters;
25
+ search: string;
26
+ pagination: DataViewPaginationState;
27
+ onSortingChange: (sorting: DataViewSorting) => void;
28
+ onFiltersChange: (filters: DataViewFilters) => void;
29
+ onSearchChange: (search: string) => void;
30
+ onPaginationChange: (pagination: DataViewPaginationState) => void;
31
+ }
32
+
33
+ export interface UseDataViewStateOptions<T> {
34
+ /** Stable key for persisted preferences; omit to keep preferences in memory only. */
35
+ viewId?: string;
36
+ columns: DataViewColumn<T>[];
37
+ /** Persistence seam; without it (or without a viewId) nothing is persisted. */
38
+ viewStateRepository?: ViewStateRepository;
39
+ /** Server-side views keep sorting/filters/search out of the persisted state. */
40
+ serverSide: boolean;
41
+ initialPageSize: number;
42
+ /** Present for server-side views driven by {@link useServerDataView}. */
43
+ controlledQuery?: DataViewControlledQuery;
44
+ }
45
+
46
+ export interface UseDataViewStateResult<T> {
47
+ sorting: DataViewSorting;
48
+ /** 3-state toggle for one column: asc → desc → none. */
49
+ toggleSort: (columnId: string) => void;
50
+ filters: DataViewFilters;
51
+ setFilter: (columnId: string, value: unknown) => void;
52
+ clearFilters: () => void;
53
+ search: string;
54
+ setSearch: (search: string) => void;
55
+ pagination: DataViewPaginationState;
56
+ setPagination: (pagination: DataViewPaginationState) => void;
57
+ columnVisibility: Record<string, boolean>;
58
+ setColumnVisible: (columnId: string, visible: boolean) => void;
59
+ columnOrder: string[];
60
+ /** Reorder a user column one step (system columns are not part of this order). */
61
+ moveColumn: (columnId: string, direction: -1 | 1) => void;
62
+ columnSizing: Record<string, number>;
63
+ /** Commit resized widths (called once per drag, on pointer-up). */
64
+ commitColumnSizing: (sizing: Record<string, number>) => void;
65
+ /** The user columns in effective order (persisted order first, new columns appended). */
66
+ orderedColumns: DataViewColumn<T>[];
67
+ /** The ordered columns that are currently visible. */
68
+ visibleColumns: DataViewColumn<T>[];
69
+ }
70
+
71
+ function orderColumns<T>(columns: DataViewColumn<T>[], order: string[]): DataViewColumn<T>[] {
72
+ if (order.length === 0) {
73
+ return columns;
74
+ }
75
+ const byId = new Map(columns.map((column) => [column.id, column]));
76
+ const ordered: DataViewColumn<T>[] = [];
77
+ for (const id of order) {
78
+ const column = byId.get(id);
79
+ if (column !== undefined) {
80
+ ordered.push(column);
81
+ byId.delete(id);
82
+ }
83
+ }
84
+ // Columns added after the order was persisted keep their declared position at the end.
85
+ ordered.push(...byId.values());
86
+ return ordered;
87
+ }
88
+
89
+ /**
90
+ * The headless state engine behind {@link DataView}: view preferences (column
91
+ * visibility / order / sizing) always live here and persist through the
92
+ * {@link ViewStateRepository}; the query state (sorting / filters / search /
93
+ * pagination) is owned here for client-side views and delegated to
94
+ * `controlledQuery` for server-side ones.
95
+ */
96
+ export function useDataViewState<T>({
97
+ viewId,
98
+ columns,
99
+ viewStateRepository,
100
+ serverSide,
101
+ initialPageSize,
102
+ controlledQuery,
103
+ }: UseDataViewStateOptions<T>): UseDataViewStateResult<T> {
104
+ // Load persisted state exactly once (lazy initializer); corrupt data already fell
105
+ // back to `undefined` inside the repository.
106
+ const [persisted] = useState<DataViewState | undefined>(() =>
107
+ viewId !== undefined && viewStateRepository !== undefined
108
+ ? viewStateRepository.load(viewId)
109
+ : undefined,
110
+ );
111
+
112
+ const [ownSorting, setOwnSorting] = useState<DataViewSorting>(
113
+ serverSide ? [] : (persisted?.sorting ?? []),
114
+ );
115
+ const [ownFilters, setOwnFilters] = useState<DataViewFilters>(
116
+ serverSide ? [] : (persisted?.filters ?? []),
117
+ );
118
+ const [ownSearch, setOwnSearch] = useState<string>(serverSide ? "" : (persisted?.search ?? ""));
119
+ const [ownPagination, setOwnPagination] = useState<DataViewPaginationState>({
120
+ pageIndex: 0,
121
+ pageSize: initialPageSize,
122
+ });
123
+ const [columnVisibility, setColumnVisibility] = useState<Record<string, boolean>>(
124
+ persisted?.columnVisibility ?? {},
125
+ );
126
+ const [columnOrder, setColumnOrder] = useState<string[]>(persisted?.columnOrder ?? []);
127
+ const [columnSizing, setColumnSizing] = useState<Record<string, number>>(
128
+ persisted?.columnSizing ?? {},
129
+ );
130
+
131
+ const sorting = controlledQuery?.sorting ?? ownSorting;
132
+ const filters = controlledQuery?.filters ?? ownFilters;
133
+ const search = controlledQuery?.search ?? ownSearch;
134
+ const pagination = controlledQuery?.pagination ?? ownPagination;
135
+
136
+ // Persist through a ref-held snapshot so the callbacks below stay stable and each
137
+ // save writes the full, current state.
138
+ const stateRef = useRef<DataViewState>(emptyDataViewState());
139
+ stateRef.current = {
140
+ columnVisibility,
141
+ columnOrder,
142
+ columnSizing,
143
+ sorting: serverSide ? [] : sorting,
144
+ filters: serverSide ? [] : filters,
145
+ search: serverSide ? "" : search,
146
+ };
147
+ const persistRef = useRef<(patch: Partial<DataViewState>) => void>(() => undefined);
148
+ persistRef.current = (patch) => {
149
+ if (viewId !== undefined && viewStateRepository !== undefined) {
150
+ viewStateRepository.save(viewId, { ...stateRef.current, ...patch });
151
+ }
152
+ };
153
+
154
+ const controlledRef = useRef(controlledQuery);
155
+ controlledRef.current = controlledQuery;
156
+ const queryRef = useRef({ sorting, filters, search, pagination });
157
+ queryRef.current = { sorting, filters, search, pagination };
158
+
159
+ const setSorting = useCallback(
160
+ (next: DataViewSorting) => {
161
+ if (controlledRef.current !== undefined) {
162
+ controlledRef.current.onSortingChange(next);
163
+ return;
164
+ }
165
+ setOwnSorting(next);
166
+ if (!serverSide) {
167
+ persistRef.current({ sorting: next });
168
+ }
169
+ },
170
+ [serverSide],
171
+ );
172
+
173
+ const toggleSort = useCallback(
174
+ (columnId: string) => {
175
+ const current = queryRef.current.sorting.find((sort) => sort.id === columnId);
176
+ const next: DataViewSorting =
177
+ current === undefined
178
+ ? [{ id: columnId, desc: false }]
179
+ : current.desc
180
+ ? []
181
+ : [{ id: columnId, desc: true }];
182
+ setSorting(next);
183
+ },
184
+ [setSorting],
185
+ );
186
+
187
+ const setPagination = useCallback((next: DataViewPaginationState) => {
188
+ if (controlledRef.current !== undefined) {
189
+ controlledRef.current.onPaginationChange(next);
190
+ } else {
191
+ setOwnPagination(next);
192
+ }
193
+ }, []);
194
+
195
+ const setFilters = useCallback(
196
+ (next: DataViewFilters) => {
197
+ if (controlledRef.current !== undefined) {
198
+ controlledRef.current.onFiltersChange(next);
199
+ } else {
200
+ setOwnFilters(next);
201
+ if (!serverSide) {
202
+ persistRef.current({ filters: next });
203
+ }
204
+ }
205
+ // A changed filter invalidates the current page position.
206
+ setPagination({ ...queryRef.current.pagination, pageIndex: 0 });
207
+ },
208
+ [serverSide, setPagination],
209
+ );
210
+
211
+ const setFilter = useCallback(
212
+ (columnId: string, value: unknown) => {
213
+ const rest = queryRef.current.filters.filter((filter) => filter.id !== columnId);
214
+ const cleared =
215
+ value === undefined || value === null || value === "" ||
216
+ (Array.isArray(value) && value.length === 0);
217
+ setFilters(cleared ? rest : [...rest, { id: columnId, value }]);
218
+ },
219
+ [setFilters],
220
+ );
221
+
222
+ const clearFilters = useCallback(() => {
223
+ setFilters([]);
224
+ }, [setFilters]);
225
+
226
+ const setSearch = useCallback(
227
+ (next: string) => {
228
+ if (controlledRef.current !== undefined) {
229
+ controlledRef.current.onSearchChange(next);
230
+ } else {
231
+ setOwnSearch(next);
232
+ if (!serverSide) {
233
+ persistRef.current({ search: next });
234
+ }
235
+ }
236
+ setPagination({ ...queryRef.current.pagination, pageIndex: 0 });
237
+ },
238
+ [serverSide, setPagination],
239
+ );
240
+
241
+ const setColumnVisible = useCallback((columnId: string, visible: boolean) => {
242
+ setColumnVisibility((current) => {
243
+ const next = { ...current, [columnId]: visible };
244
+ persistRef.current({ columnVisibility: next });
245
+ return next;
246
+ });
247
+ }, []);
248
+
249
+ const columnIds = useMemo(() => columns.map((column) => column.id), [columns]);
250
+
251
+ const moveColumn = useCallback(
252
+ (columnId: string, direction: -1 | 1) => {
253
+ setColumnOrder((current) => {
254
+ const effective = current.length > 0 ? [...current] : [...columnIds];
255
+ // Only known user columns take part; stale persisted ids are dropped.
256
+ const order = effective.filter((id) => columnIds.includes(id));
257
+ for (const id of columnIds) {
258
+ if (!order.includes(id)) {
259
+ order.push(id);
260
+ }
261
+ }
262
+ const from = order.indexOf(columnId);
263
+ const to = from + direction;
264
+ if (from === -1 || to < 0 || to >= order.length) {
265
+ return current;
266
+ }
267
+ const next = [...order];
268
+ next.splice(from, 1);
269
+ next.splice(to, 0, columnId);
270
+ persistRef.current({ columnOrder: next });
271
+ return next;
272
+ });
273
+ },
274
+ [columnIds],
275
+ );
276
+
277
+ const commitColumnSizing = useCallback((sizing: Record<string, number>) => {
278
+ setColumnSizing((current) => {
279
+ const next = { ...current, ...sizing };
280
+ persistRef.current({ columnSizing: next });
281
+ return next;
282
+ });
283
+ }, []);
284
+
285
+ const orderedColumns = useMemo(() => orderColumns(columns, columnOrder), [columns, columnOrder]);
286
+ const visibleColumns = useMemo(
287
+ () => orderedColumns.filter((column) => columnVisibility[column.id] !== false),
288
+ [orderedColumns, columnVisibility],
289
+ );
290
+
291
+ return {
292
+ sorting,
293
+ toggleSort,
294
+ filters,
295
+ setFilter,
296
+ clearFilters,
297
+ search,
298
+ setSearch,
299
+ pagination,
300
+ setPagination,
301
+ columnVisibility,
302
+ setColumnVisible,
303
+ columnOrder,
304
+ moveColumn,
305
+ columnSizing,
306
+ commitColumnSizing,
307
+ orderedColumns,
308
+ visibleColumns,
309
+ };
310
+ }
@@ -0,0 +1,154 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ import type {
4
+ DataViewControlledQuery,
5
+ DataViewFilters,
6
+ DataViewPaginationState,
7
+ DataViewSorting,
8
+ } from "./useDataViewState";
9
+
10
+ export interface UseServerDataViewOptions {
11
+ /** Prefix for the URL parameters, so several views can share one page. */
12
+ paramPrefix?: string;
13
+ initialPageSize?: number;
14
+ }
15
+
16
+ interface UrlQueryState {
17
+ sorting: DataViewSorting;
18
+ filters: DataViewFilters;
19
+ search: string;
20
+ pagination: DataViewPaginationState;
21
+ }
22
+
23
+ function readUrlState(prefix: string, initialPageSize: number): UrlQueryState {
24
+ const params = new URLSearchParams(window.location.search);
25
+ const read = (key: string) => params.get(prefix === "" ? key : `${prefix}.${key}`);
26
+
27
+ const page = Number(read("page") ?? "1");
28
+ const size = Number(read("size") ?? String(initialPageSize));
29
+ const sorting: DataViewSorting = (read("sort") ?? "")
30
+ .split(",")
31
+ .filter((entry) => entry !== "")
32
+ .map((entry) =>
33
+ entry.startsWith("-") ? { id: entry.slice(1), desc: true } : { id: entry, desc: false },
34
+ );
35
+
36
+ let filters: DataViewFilters = [];
37
+ const rawFilters = read("filters");
38
+ if (rawFilters !== null) {
39
+ try {
40
+ const parsed: unknown = JSON.parse(rawFilters);
41
+ if (Array.isArray(parsed)) {
42
+ filters = parsed.filter(
43
+ (entry): entry is { id: string; value: unknown } =>
44
+ typeof entry === "object" && entry !== null && typeof (entry as { id?: unknown }).id === "string",
45
+ );
46
+ }
47
+ } catch {
48
+ // A malformed filters param falls back to no filters.
49
+ }
50
+ }
51
+
52
+ return {
53
+ sorting,
54
+ filters,
55
+ search: read("q") ?? "",
56
+ pagination: {
57
+ pageIndex: Number.isFinite(page) && page >= 1 ? page - 1 : 0,
58
+ pageSize: Number.isFinite(size) && size > 0 ? size : initialPageSize,
59
+ },
60
+ };
61
+ }
62
+
63
+ function writeUrlState(prefix: string, state: UrlQueryState, initialPageSize: number): void {
64
+ const params = new URLSearchParams(window.location.search);
65
+ const set = (key: string, value: string | undefined) => {
66
+ const name = prefix === "" ? key : `${prefix}.${key}`;
67
+ if (value === undefined) {
68
+ params.delete(name);
69
+ } else {
70
+ params.set(name, value);
71
+ }
72
+ };
73
+
74
+ set("page", state.pagination.pageIndex > 0 ? String(state.pagination.pageIndex + 1) : undefined);
75
+ set(
76
+ "size",
77
+ state.pagination.pageSize !== initialPageSize ? String(state.pagination.pageSize) : undefined,
78
+ );
79
+ set(
80
+ "sort",
81
+ state.sorting.length > 0
82
+ ? state.sorting.map((sort) => (sort.desc ? `-${sort.id}` : sort.id)).join(",")
83
+ : undefined,
84
+ );
85
+ set("filters", state.filters.length > 0 ? JSON.stringify(state.filters) : undefined);
86
+ set("q", state.search !== "" ? state.search : undefined);
87
+
88
+ const query = params.toString();
89
+ const url = `${window.location.pathname}${query === "" ? "" : `?${query}`}${window.location.hash}`;
90
+ window.history.replaceState(window.history.state, "", url);
91
+ }
92
+
93
+ /**
94
+ * Query state for **server-side** views, kept in the URL (page, size, sort, filters,
95
+ * search) so a view survives reloads and can be deep-linked — the server-side analog
96
+ * of the client-side persistence a {@link ViewStateRepository} provides.
97
+ *
98
+ * @example
99
+ * ```tsx
100
+ * const server = useServerDataView({ initialPageSize: 25 });
101
+ * <DataView repository={httpRepo} columns={columns} serverQuery={server} />
102
+ * ```
103
+ */
104
+ export function useServerDataView({
105
+ paramPrefix = "",
106
+ initialPageSize = 10,
107
+ }: UseServerDataViewOptions = {}): DataViewControlledQuery {
108
+ const [state, setState] = useState<UrlQueryState>(() =>
109
+ readUrlState(paramPrefix, initialPageSize),
110
+ );
111
+
112
+ // Re-read on history navigation so back/forward restores the view.
113
+ useEffect(() => {
114
+ const onPopState = () => setState(readUrlState(paramPrefix, initialPageSize));
115
+ window.addEventListener("popstate", onPopState);
116
+ return () => window.removeEventListener("popstate", onPopState);
117
+ }, [paramPrefix, initialPageSize]);
118
+
119
+ const update = useCallback(
120
+ (patch: Partial<UrlQueryState>) => {
121
+ setState((current) => {
122
+ const next = { ...current, ...patch };
123
+ writeUrlState(paramPrefix, next, initialPageSize);
124
+ return next;
125
+ });
126
+ },
127
+ [paramPrefix, initialPageSize],
128
+ );
129
+
130
+ const onSortingChange = useCallback(
131
+ (sorting: DataViewSorting) => update({ sorting }),
132
+ [update],
133
+ );
134
+ const onFiltersChange = useCallback(
135
+ (filters: DataViewFilters) => update({ filters }),
136
+ [update],
137
+ );
138
+ const onSearchChange = useCallback((search: string) => update({ search }), [update]);
139
+ const onPaginationChange = useCallback(
140
+ (pagination: DataViewPaginationState) => update({ pagination }),
141
+ [update],
142
+ );
143
+
144
+ return {
145
+ sorting: state.sorting,
146
+ filters: state.filters,
147
+ search: state.search,
148
+ pagination: state.pagination,
149
+ onSortingChange,
150
+ onFiltersChange,
151
+ onSearchChange,
152
+ onPaginationChange,
153
+ };
154
+ }
@@ -0,0 +1,68 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export interface UseViewSearchResult {
4
+ /** The immediate input value (what the user is typing). */
5
+ inputValue: string;
6
+ /** Update the input; the outer `onChange` fires after the debounce window. */
7
+ setInputValue: (value: string) => void;
8
+ /** Clear the input and emit "" immediately (the × button). */
9
+ clear: () => void;
10
+ }
11
+
12
+ /**
13
+ * Controlled search-input state with an optional debounce: the input updates
14
+ * immediately, the outer `onChange` (which typically triggers a repository query)
15
+ * fires only after `debounceMs` of quiet. External changes to `value` (e.g. a
16
+ * "clear filters" reset) sync back into the input.
17
+ */
18
+ export function useViewSearch(
19
+ value: string,
20
+ onChange: (value: string) => void,
21
+ debounceMs = 0,
22
+ ): UseViewSearchResult {
23
+ const [inputValue, setInput] = useState(value);
24
+ const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
25
+ const onChangeRef = useRef(onChange);
26
+ onChangeRef.current = onChange;
27
+ // Track the last value we emitted, so an echo of our own change does not clobber
28
+ // what the user typed since.
29
+ const lastEmittedRef = useRef(value);
30
+
31
+ useEffect(() => {
32
+ if (value !== lastEmittedRef.current) {
33
+ // An external reset wins: cancel any pending debounced emit so it cannot
34
+ // resurrect the value the caller just cleared.
35
+ clearTimeout(timerRef.current);
36
+ lastEmittedRef.current = value;
37
+ setInput(value);
38
+ }
39
+ }, [value]);
40
+
41
+ useEffect(() => () => clearTimeout(timerRef.current), []);
42
+
43
+ const emit = useCallback((next: string) => {
44
+ lastEmittedRef.current = next;
45
+ onChangeRef.current(next);
46
+ }, []);
47
+
48
+ const setInputValue = useCallback(
49
+ (next: string) => {
50
+ setInput(next);
51
+ clearTimeout(timerRef.current);
52
+ if (debounceMs <= 0) {
53
+ emit(next);
54
+ } else {
55
+ timerRef.current = setTimeout(() => emit(next), debounceMs);
56
+ }
57
+ },
58
+ [debounceMs, emit],
59
+ );
60
+
61
+ const clear = useCallback(() => {
62
+ setInput("");
63
+ clearTimeout(timerRef.current);
64
+ emit("");
65
+ }, [emit]);
66
+
67
+ return { inputValue, setInputValue, clear };
68
+ }
@@ -0,0 +1,62 @@
1
+ // The DataView family — the single sanctioned surface for rendering data collections.
2
+ // See ./README.md for usage.
3
+ export { DataView } from "./DataView";
4
+ export type { DataViewProps } from "./DataView";
5
+ export { DataViewToolbar } from "./DataViewToolbar";
6
+ export type { DataViewToolbarProps } from "./DataViewToolbar";
7
+ export { DataViewPagination } from "./DataViewPagination";
8
+ export type { DataViewPaginationProps } from "./DataViewPagination";
9
+ export { DataViewColumnSettings } from "./DataViewColumnSettings";
10
+ export type { DataViewColumnSettingsProps } from "./DataViewColumnSettings";
11
+ export { DataViewRowActions } from "./DataViewRowActions";
12
+ export type { DataViewRowActionsLayout, DataViewRowActionsProps } from "./DataViewRowActions";
13
+ export { DataViewExpandableRow, DataViewExpandToggle } from "./DataViewExpandableRow";
14
+ export { DataViewCardList } from "./DataViewCardList";
15
+ export type { DataViewCardListProps } from "./DataViewCardList";
16
+ export { DataViewTable } from "./DataViewTable";
17
+ export type { DataViewTableProps } from "./DataViewTable";
18
+
19
+ export type {
20
+ DataViewQuery,
21
+ DataViewResult,
22
+ DataViewRepository,
23
+ DataViewState,
24
+ ViewStateRepository,
25
+ DataViewColumn,
26
+ DataViewColumnMeta,
27
+ DataViewMobileSlot,
28
+ DataViewRowAction,
29
+ DataViewBatchAction,
30
+ DataViewSearchScope,
31
+ DataViewStrings,
32
+ } from "./types";
33
+ export { DEFAULT_DATA_VIEW_STRINGS, emptyDataViewState } from "./types";
34
+
35
+ export { InMemoryDataViewRepository } from "./repositories/InMemoryDataViewRepository";
36
+ export type { InMemoryDataViewRepositoryOptions } from "./repositories/InMemoryDataViewRepository";
37
+ export { HttpDataViewRepository } from "./repositories/HttpDataViewRepository";
38
+ export type {
39
+ HttpDataViewParams,
40
+ HttpDataViewRepositoryOptions,
41
+ HttpRequestAdapter,
42
+ } from "./repositories/HttpDataViewRepository";
43
+ export {
44
+ InMemoryViewStateRepository,
45
+ LocalStorageViewStateRepository,
46
+ } from "./repositories/viewState";
47
+
48
+ export { useDataViewState } from "./hooks/useDataViewState";
49
+ export type {
50
+ DataViewControlledQuery,
51
+ DataViewFilters,
52
+ DataViewPaginationState,
53
+ DataViewSorting,
54
+ UseDataViewStateOptions,
55
+ UseDataViewStateResult,
56
+ } from "./hooks/useDataViewState";
57
+ export { useServerDataView } from "./hooks/useServerDataView";
58
+ export type { UseServerDataViewOptions } from "./hooks/useServerDataView";
59
+ export { useViewSearch } from "./hooks/useViewSearch";
60
+ export type { UseViewSearchResult } from "./hooks/useViewSearch";
61
+ export { useDataViewQuery } from "./hooks/useDataViewQuery";
62
+ export type { UseDataViewQueryResult } from "./hooks/useDataViewQuery";
@@ -0,0 +1,96 @@
1
+ import { createContext, useContext } from "react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { injectTerpStyles } from "../styles";
5
+ import { Menu, MenuItem } from "../ui/Menu";
6
+ import { useUiText } from "../uiText";
7
+ import type { ResolveUiText, UiText } from "../uiText";
8
+ import { DEFAULT_DATA_VIEW_STRINGS, formatDataViewString } from "./types";
9
+ import type { DataViewStrings } from "./types";
10
+
11
+ injectTerpStyles();
12
+
13
+ /** Internal: merged strings + resolver every DataView sub-component reads. */
14
+ export interface DataViewTextApi {
15
+ strings: DataViewStrings;
16
+ resolve: ResolveUiText;
17
+ /** Resolve a countable string and fill its `{placeholder}`s. */
18
+ format: (text: UiText, values: Record<string, string | number>) => string;
19
+ }
20
+
21
+ const DataViewTextContext = createContext<DataViewTextApi>({
22
+ strings: DEFAULT_DATA_VIEW_STRINGS,
23
+ resolve: (text) => (typeof text === "string" ? text : text.message),
24
+ format: (text, values) =>
25
+ formatDataViewString(typeof text === "string" ? text : text.message, values),
26
+ });
27
+
28
+ export function useDataViewText(): DataViewTextApi {
29
+ return useContext(DataViewTextContext);
30
+ }
31
+
32
+ export function DataViewTextProvider({
33
+ overrides,
34
+ children,
35
+ }: {
36
+ overrides?: Partial<DataViewStrings>;
37
+ children: ReactNode;
38
+ }) {
39
+ const resolve = useUiText();
40
+ const strings: DataViewStrings = { ...DEFAULT_DATA_VIEW_STRINGS, ...overrides };
41
+ const api: DataViewTextApi = {
42
+ strings,
43
+ resolve,
44
+ format: (text, values) => formatDataViewString(resolve(text), values),
45
+ };
46
+ return <DataViewTextContext.Provider value={api}>{children}</DataViewTextContext.Provider>;
47
+ }
48
+
49
+ /**
50
+ * Internal DataView wrapper over the shared react-core Menu primitive.
51
+ */
52
+ export function DataViewMenu({
53
+ trigger,
54
+ triggerLabel,
55
+ align = "end",
56
+ children,
57
+ }: {
58
+ /** Trigger content (an icon or a label). */
59
+ trigger: ReactNode;
60
+ /** Accessible name of the trigger button. */
61
+ triggerLabel: string;
62
+ align?: "start" | "end";
63
+ /** Panel content; render-prop so items can close the menu after acting. */
64
+ children: (close: () => void) => ReactNode;
65
+ }) {
66
+ return (
67
+ <Menu trigger={trigger} triggerLabel={triggerLabel} align={align}>
68
+ {({ close }) => children(() => close(false))}
69
+ </Menu>
70
+ );
71
+ }
72
+
73
+ /** Internal: one item inside a {@link DataViewMenu}. */
74
+ export function DataViewMenuItem({
75
+ label,
76
+ destructive = false,
77
+ disabled = false,
78
+ icon,
79
+ onSelect,
80
+ }: {
81
+ label: string;
82
+ destructive?: boolean;
83
+ disabled?: boolean;
84
+ icon?: ReactNode;
85
+ onSelect: () => void;
86
+ }) {
87
+ return (
88
+ <MenuItem
89
+ label={label}
90
+ icon={icon}
91
+ destructive={destructive}
92
+ disabled={disabled}
93
+ onSelect={onSelect}
94
+ />
95
+ );
96
+ }