@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,392 @@
1
+ // @vitest-environment jsdom
2
+ import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
3
+ import { afterEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { DataView } from "./DataView";
6
+ import { InMemoryDataViewRepository } from "./repositories/InMemoryDataViewRepository";
7
+ import { InMemoryViewStateRepository } from "./repositories/viewState";
8
+ import type { DataViewColumn, DataViewQuery, DataViewRepository } from "./types";
9
+
10
+ afterEach(() => {
11
+ cleanup();
12
+ vi.restoreAllMocks();
13
+ });
14
+
15
+ interface Ticket {
16
+ id: string;
17
+ title: string;
18
+ status: string;
19
+ }
20
+
21
+ const TICKETS: Ticket[] = [
22
+ { id: "1", title: "Broken printer", status: "open" },
23
+ { id: "2", title: "VPN access", status: "closed" },
24
+ { id: "3", title: "New laptop", status: "open" },
25
+ { id: "4", title: "Password reset", status: "open" },
26
+ ];
27
+
28
+ const COLUMNS: DataViewColumn<Ticket>[] = [
29
+ { id: "title", header: "Title", accessor: (t) => t.title, meta: { mobileSlot: "title" } },
30
+ { id: "status", header: "Status", accessor: (t) => t.status, meta: { mobileSlot: "status" } },
31
+ ];
32
+
33
+ function inMemoryRepo(rows: Ticket[] = TICKETS) {
34
+ return new InMemoryDataViewRepository(rows, {
35
+ getRowId: (t) => t.id,
36
+ getValue: (t, col) => t[col as keyof Ticket],
37
+ searchFields: ["title"],
38
+ });
39
+ }
40
+
41
+ describe("DataView states", () => {
42
+ it("shows a loading skeleton, then the rows", async () => {
43
+ render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
44
+ expect(screen.getByRole("status", { name: "Loading…" })).toBeInTheDocument();
45
+ expect(await screen.findByText("Broken printer")).toBeInTheDocument();
46
+ expect(screen.queryByRole("status", { name: "Loading…" })).not.toBeInTheDocument();
47
+ expect(screen.getByText("1–4 of 4 results")).toBeInTheDocument();
48
+ });
49
+
50
+ it("shows the empty state with the emptyActions slot", async () => {
51
+ render(
52
+ <DataView
53
+ repository={inMemoryRepo([])}
54
+ columns={COLUMNS}
55
+ emptyMessage="No tickets."
56
+ emptyActions={<button type="button">Reset</button>}
57
+ />,
58
+ );
59
+ expect(await screen.findByText("No tickets.")).toBeInTheDocument();
60
+ expect(screen.getByRole("button", { name: "Reset" })).toBeInTheDocument();
61
+ });
62
+
63
+ it("shows the error state when the repository rejects", async () => {
64
+ const failing: DataViewRepository<Ticket> = {
65
+ query: () => Promise.reject(new Error("boom")),
66
+ getRowId: (t) => t.id,
67
+ capabilities: { serverSide: false, search: false, searchScope: false },
68
+ };
69
+ render(<DataView repository={failing} columns={COLUMNS} />);
70
+ expect(await screen.findByRole("alert")).toHaveTextContent("Could not load data.");
71
+ });
72
+ });
73
+
74
+ describe("DataView server-side mode", () => {
75
+ it("pushes pagination into the repository query and uses the returned total", async () => {
76
+ const queries: DataViewQuery[] = [];
77
+ const server: DataViewRepository<Ticket> = {
78
+ query: (q) => {
79
+ queries.push(q);
80
+ const start = q.pagination.pageIndex * q.pagination.pageSize;
81
+ return Promise.resolve({ rows: TICKETS.slice(start, start + q.pagination.pageSize), totalCount: 4 });
82
+ },
83
+ getRowId: (t) => t.id,
84
+ capabilities: { serverSide: true, search: true, searchScope: false },
85
+ };
86
+ render(<DataView repository={server} columns={COLUMNS} initialPageSize={2} />);
87
+ expect(await screen.findByText("Broken printer")).toBeInTheDocument();
88
+ expect(screen.getByText("1–2 of 4 results")).toBeInTheDocument();
89
+
90
+ fireEvent.click(screen.getByRole("button", { name: "Next page" }));
91
+ expect(await screen.findByText("New laptop")).toBeInTheDocument();
92
+ expect(queries.at(-1)?.pagination).toEqual({ pageIndex: 1, pageSize: 2 });
93
+ });
94
+
95
+ it("snaps an out-of-range page back to the last valid page", async () => {
96
+ const queries: DataViewQuery[] = [];
97
+ const server: DataViewRepository<Ticket> = {
98
+ query: (q) => {
99
+ queries.push(q);
100
+ const start = q.pagination.pageIndex * q.pagination.pageSize;
101
+ return Promise.resolve({ rows: TICKETS.slice(start, start + q.pagination.pageSize), totalCount: 4 });
102
+ },
103
+ getRowId: (t) => t.id,
104
+ capabilities: { serverSide: true, search: true, searchScope: false },
105
+ };
106
+ const onPaginationChange = vi.fn();
107
+ render(
108
+ <DataView
109
+ repository={server}
110
+ columns={COLUMNS}
111
+ serverQuery={{
112
+ sorting: [],
113
+ filters: [],
114
+ search: "",
115
+ pagination: { pageIndex: 49, pageSize: 2 }, // e.g. a stale ?page=50 deep link
116
+ onSortingChange: vi.fn(),
117
+ onFiltersChange: vi.fn(),
118
+ onSearchChange: vi.fn(),
119
+ onPaginationChange,
120
+ }}
121
+ />,
122
+ );
123
+ // The out-of-range query resolves empty, then pagination snaps to the last page.
124
+ await waitFor(() =>
125
+ expect(onPaginationChange).toHaveBeenCalledWith({ pageIndex: 1, pageSize: 2 }),
126
+ );
127
+ expect(queries.at(0)?.pagination).toEqual({ pageIndex: 49, pageSize: 2 });
128
+ });
129
+ });
130
+
131
+ describe("DataView layout switching", () => {
132
+ it("switches between table and cards via the explicit toggle", async () => {
133
+ render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
134
+ expect(await screen.findByRole("table")).toBeInTheDocument();
135
+
136
+ fireEvent.click(screen.getByRole("button", { name: "Card view" }));
137
+ expect(screen.queryByRole("table")).not.toBeInTheDocument();
138
+ expect(screen.getByRole("list")).toBeInTheDocument();
139
+ expect(screen.getByText("Broken printer")).toBeInTheDocument();
140
+
141
+ fireEvent.click(screen.getByRole("button", { name: "Table view" }));
142
+ expect(screen.getByRole("table")).toBeInTheDocument();
143
+ });
144
+ });
145
+
146
+ describe("DataView selection and batch actions", () => {
147
+ it("selects across pages: page select, select-all mode, onSelectAll dispatch, reset on break", async () => {
148
+ const onClick = vi.fn();
149
+ const onSelectAll = vi.fn();
150
+ render(
151
+ <DataView
152
+ repository={inMemoryRepo()}
153
+ columns={COLUMNS}
154
+ initialPageSize={2}
155
+ enableSelection
156
+ batchActions={[{ label: "Archive", onClick, onSelectAll, inline: true }]}
157
+ />,
158
+ );
159
+ await screen.findByText("Broken printer");
160
+
161
+ fireEvent.click(screen.getByRole("checkbox", { name: "Select all rows on this page" }));
162
+ expect(screen.getByText("2 selected")).toBeInTheDocument();
163
+
164
+ fireEvent.click(screen.getByRole("button", { name: "Select all 4 results" }));
165
+ fireEvent.click(screen.getByRole("button", { name: "Archive" }));
166
+ expect(onSelectAll).toHaveBeenCalledTimes(1);
167
+ expect(onClick).not.toHaveBeenCalled();
168
+
169
+ // Breaking the page selection resets select-all-across-pages mode.
170
+ fireEvent.click(screen.getAllByRole("checkbox", { name: "Select row" })[0]!);
171
+ fireEvent.click(screen.getByRole("button", { name: "Archive" }));
172
+ expect(onClick).toHaveBeenCalledTimes(1);
173
+ expect(onClick.mock.calls[0]?.[0]).toHaveLength(1);
174
+ });
175
+
176
+ it("clears the selection from the toolbar", async () => {
177
+ render(
178
+ <DataView repository={inMemoryRepo()} columns={COLUMNS} enableSelection />,
179
+ );
180
+ await screen.findByText("Broken printer");
181
+ fireEvent.click(screen.getAllByRole("checkbox", { name: "Select row" })[0]!);
182
+ expect(screen.getByText("1 selected")).toBeInTheDocument();
183
+ fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
184
+ expect(screen.queryByText("1 selected")).not.toBeInTheDocument();
185
+ });
186
+
187
+ it("invalidates the selection when the query scope changes underneath it", async () => {
188
+ const server: DataViewRepository<Ticket> = {
189
+ query: (q) =>
190
+ Promise.resolve({
191
+ rows: TICKETS.filter((t) => t.title.includes(q.search)),
192
+ totalCount: 4,
193
+ }),
194
+ getRowId: (t) => t.id,
195
+ capabilities: { serverSide: true, search: true, searchScope: false },
196
+ };
197
+ const controlled = {
198
+ sorting: [],
199
+ filters: [],
200
+ search: "",
201
+ pagination: { pageIndex: 0, pageSize: 10 },
202
+ onSortingChange: vi.fn(),
203
+ onFiltersChange: vi.fn(),
204
+ onSearchChange: vi.fn(),
205
+ onPaginationChange: vi.fn(),
206
+ };
207
+ const { rerender } = render(
208
+ <DataView repository={server} columns={COLUMNS} enableSelection serverQuery={controlled} />,
209
+ );
210
+ await screen.findByText("Broken printer");
211
+ fireEvent.click(screen.getByRole("checkbox", { name: "Select all rows on this page" }));
212
+ expect(screen.getByText("4 selected")).toBeInTheDocument();
213
+
214
+ // The search changes externally (e.g. URL back/forward) — a different result
215
+ // set must never inherit the old "all results" selection.
216
+ rerender(
217
+ <DataView
218
+ repository={server}
219
+ columns={COLUMNS}
220
+ enableSelection
221
+ serverQuery={{ ...controlled, search: "laptop" }}
222
+ />,
223
+ );
224
+ await waitFor(() => expect(screen.queryByText("4 selected")).not.toBeInTheDocument());
225
+ });
226
+ });
227
+
228
+ describe("DataView row actions", () => {
229
+ it("honours disabled/hidden predicates per row", async () => {
230
+ const onDelete = vi.fn();
231
+ render(
232
+ <DataView
233
+ repository={inMemoryRepo()}
234
+ columns={COLUMNS}
235
+ rowActionsLayout="inline"
236
+ rowActions={(t) => [
237
+ {
238
+ label: "Delete",
239
+ onClick: onDelete,
240
+ variant: "destructive",
241
+ disabled: (row: Ticket) => row.status === "closed",
242
+ hidden: (row: Ticket) => row.id === "4",
243
+ },
244
+ ]}
245
+ />,
246
+ );
247
+ await screen.findByText("Broken printer");
248
+
249
+ const deleteButtons = screen.getAllByRole("button", { name: "Delete" });
250
+ expect(deleteButtons).toHaveLength(3); // hidden for ticket 4
251
+ expect(deleteButtons[1]).toBeDisabled(); // VPN access is closed
252
+
253
+ fireEvent.click(deleteButtons[0]!);
254
+ expect(onDelete).toHaveBeenCalledWith(TICKETS[0]);
255
+ });
256
+
257
+ it("does not trigger row click from an action cell", async () => {
258
+ const onRowClick = vi.fn();
259
+ render(
260
+ <DataView
261
+ repository={inMemoryRepo()}
262
+ columns={COLUMNS}
263
+ getRowLabel={(ticket) => ticket.title}
264
+ onRowClick={onRowClick}
265
+ rowActionsLayout="inline"
266
+ rowActions={() => [{ label: "Open", onClick: vi.fn() }]}
267
+ />,
268
+ );
269
+ await screen.findByText("Broken printer");
270
+ fireEvent.click(screen.getAllByRole("button", { name: "Open" })[0]!);
271
+ expect(onRowClick).not.toHaveBeenCalled();
272
+
273
+ fireEvent.click(screen.getByText("Broken printer"));
274
+ expect(onRowClick).toHaveBeenCalledWith(TICKETS[0]);
275
+ });
276
+
277
+ it("exposes record-labelled native activation buttons in table and card views", async () => {
278
+ const onRowClick = vi.fn();
279
+ render(
280
+ <DataView
281
+ repository={inMemoryRepo()}
282
+ columns={COLUMNS}
283
+ getRowLabel={(ticket) => ticket.title}
284
+ onRowClick={onRowClick}
285
+ />,
286
+ );
287
+ await screen.findByText("Broken printer");
288
+ fireEvent.click(screen.getByRole("button", { name: "Open details: Broken printer" }));
289
+ expect(onRowClick).toHaveBeenLastCalledWith(TICKETS[0]);
290
+
291
+ fireEvent.click(screen.getByRole("button", { name: "Card view" }));
292
+ await screen.findByText("Broken printer");
293
+ fireEvent.click(screen.getByRole("button", { name: "Open details: Broken printer" }));
294
+ expect(onRowClick).toHaveBeenLastCalledWith(TICKETS[0]);
295
+ });
296
+ });
297
+
298
+ describe("DataView expandable rows", () => {
299
+ it("toggles the full-width detail panel", async () => {
300
+ render(
301
+ <DataView
302
+ repository={inMemoryRepo()}
303
+ columns={COLUMNS}
304
+ renderExpanded={(t) => <div>Detail: {t.title}</div>}
305
+ />,
306
+ );
307
+ await screen.findByText("Broken printer");
308
+ fireEvent.click(screen.getAllByRole("button", { name: "Expand row" })[0]!);
309
+ expect(screen.getByText("Detail: Broken printer")).toBeInTheDocument();
310
+ fireEvent.click(screen.getByRole("button", { name: "Collapse row" }));
311
+ expect(screen.queryByText("Detail: Broken printer")).not.toBeInTheDocument();
312
+ });
313
+ });
314
+
315
+ describe("DataView column resizing", () => {
316
+ it("persists resized widths once, on pointer-up", async () => {
317
+ const store = new InMemoryViewStateRepository();
318
+ const save = vi.spyOn(store, "save");
319
+ render(
320
+ <DataView
321
+ repository={inMemoryRepo()}
322
+ columns={COLUMNS}
323
+ viewId="tickets.list"
324
+ viewStateRepository={store}
325
+ />,
326
+ );
327
+ await screen.findByText("Broken printer");
328
+ save.mockClear();
329
+
330
+ const handle = screen.getAllByRole("separator")[0]!;
331
+ fireEvent.pointerDown(handle, { clientX: 100 });
332
+ fireEvent.pointerMove(window, { clientX: 140 });
333
+ fireEvent.pointerMove(window, { clientX: 180 });
334
+ expect(save).not.toHaveBeenCalled(); // no persistence writes per pointermove
335
+ fireEvent.pointerUp(window);
336
+ expect(save).toHaveBeenCalledTimes(1);
337
+ expect(store.load("tickets.list")?.columnSizing.title).toBeGreaterThanOrEqual(60);
338
+ });
339
+ });
340
+
341
+ describe("DataView search and view options", () => {
342
+ it("filters via the toolbar search and clears with the × button", async () => {
343
+ render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
344
+ await screen.findByText("Broken printer");
345
+
346
+ fireEvent.change(screen.getByRole("searchbox"), { target: { value: "laptop" } });
347
+ await waitFor(() => expect(screen.queryByText("Broken printer")).not.toBeInTheDocument());
348
+ expect(screen.getByText("New laptop")).toBeInTheDocument();
349
+
350
+ fireEvent.click(screen.getByRole("button", { name: "Clear search" }));
351
+ expect(await screen.findByText("Broken printer")).toBeInTheDocument();
352
+ });
353
+
354
+ it("hides and reorders columns from the view-options menu", async () => {
355
+ render(<DataView repository={inMemoryRepo()} columns={COLUMNS} />);
356
+ await screen.findByText("Broken printer");
357
+
358
+ fireEvent.click(screen.getByRole("button", { name: "View options" }));
359
+ const menu = screen.getByRole("menu");
360
+ fireEvent.click(within(menu).getByRole("checkbox", { name: "Status" }));
361
+ expect(screen.queryByRole("columnheader", { name: /Status/ })).not.toBeInTheDocument();
362
+
363
+ fireEvent.click(within(menu).getByRole("checkbox", { name: "Status" }));
364
+ fireEvent.click(within(menu).getByRole("button", { name: "Move up: Status" }));
365
+ const headers = screen.getAllByRole("columnheader").map((th) => th.textContent);
366
+ expect(headers[0]).toContain("Status");
367
+ });
368
+ });
369
+
370
+ describe("DataView embedded variant", () => {
371
+ it("keeps real search controls but omits pagination and view controls", async () => {
372
+ render(<DataView repository={inMemoryRepo()} columns={COLUMNS} variant="embedded" />);
373
+ await screen.findByText("Broken printer");
374
+ expect(screen.getByRole("searchbox")).toBeInTheDocument();
375
+ expect(screen.queryByText(/of 4 results/)).not.toBeInTheDocument();
376
+ expect(screen.queryByRole("button", { name: "Card view" })).not.toBeInTheDocument();
377
+ expect(screen.queryByRole("combobox", { name: "Rows per page" })).not.toBeInTheDocument();
378
+ // All rows rendered — the parent owns paging.
379
+ expect(screen.getAllByRole("row")).toHaveLength(5);
380
+ });
381
+
382
+ it("does not render an empty toolbar band for a non-searchable embedded view", async () => {
383
+ const repository: DataViewRepository<Ticket> = {
384
+ query: async () => ({ rows: TICKETS, totalCount: TICKETS.length }),
385
+ getRowId: (ticket) => ticket.id,
386
+ capabilities: { serverSide: false, search: false, searchScope: false },
387
+ };
388
+ render(<DataView repository={repository} columns={COLUMNS} variant="embedded" />);
389
+ await screen.findByText("Broken printer");
390
+ expect(document.querySelector('[data-terp="dataview-toolbar"]')).not.toBeInTheDocument();
391
+ });
392
+ });