@12-apps/ui 1.5.2 → 1.6.1

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 (44) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
  3. package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
  4. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
  5. package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
  6. package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
  7. package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
  8. package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
  9. package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
  10. package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
  11. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
  12. package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
  13. package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
  14. package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
  15. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
  16. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
  17. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
  18. package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
  20. package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
  21. package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
  22. package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
  23. package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
  24. package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
  25. package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
  26. package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
  27. package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
  28. package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
  30. package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
  31. package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
  32. package/src/components/data-display/DataViews/data-views-types.ts +88 -9
  33. package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
  34. package/src/components/data-display/DataViews/index.ts +14 -1
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
  36. package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
  38. package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
  39. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
  40. package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
  41. package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
  42. package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
  43. package/src/components/utility/InstallPrompt/index.ts +8 -0
  44. package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@12-apps/ui",
3
- "version": "1.5.2",
3
+ "version": "1.6.1",
4
4
  "license": "MIT",
5
5
  "packageManager": "pnpm@9.0.0",
6
6
  "type": "module",
@@ -194,7 +194,7 @@
194
194
  "react-dom": "^18.0.0 || ^19.0.0"
195
195
  },
196
196
  "dependencies": {
197
- "@12-apps/forms-core": "^1.4.3",
197
+ "@12-apps/forms-core": "^1.5.1",
198
198
  "@emotion/react": "^11.14.0",
199
199
  "@emotion/styled": "^11.14.0",
200
200
  "@monaco-editor/react": "^4.7.0",
@@ -209,8 +209,8 @@
209
209
  "recharts": "^3.5.1"
210
210
  },
211
211
  "devDependencies": {
212
- "@12-apps/eslint-config": "^1.5.3",
213
- "@12-apps/typescript-config": "^1.5.3",
212
+ "@12-apps/eslint-config": "^1.6.1",
213
+ "@12-apps/typescript-config": "^1.6.1",
214
214
  "@playwright/test": "^1.55.0",
215
215
  "@storybook/addon-docs": "^9.1.10",
216
216
  "@storybook/addon-links": "^9.1.10",
@@ -0,0 +1,327 @@
1
+ "use client";
2
+
3
+ import { Box } from "../../../mui/Box";
4
+ import { Text } from "../../typography/Text";
5
+
6
+ import type { DataViewCardSelection } from "./data-views-types";
7
+
8
+ /**
9
+ * One column of the board — a single value of the grouping field, with the label
10
+ * to print above it.
11
+ *
12
+ * `tone` is an opaque palette hint (e.g. `"success"`, `"error"`) the host picks
13
+ * from its own state machine; the board only uses it to colour the column's
14
+ * header accent, so an unknown tone degrades to the default rather than throwing.
15
+ */
16
+ export interface BoardGroup {
17
+ value: string;
18
+ label: string;
19
+ tone?: string;
20
+ }
21
+
22
+ /**
23
+ * The BOARD layout's configuration: the same page the table rendered, laid out
24
+ * as columns of the entity's own card.
25
+ *
26
+ * The board earns its place when a page's buckets are STATES IN A STATE MACHINE
27
+ * rather than arbitrary groupings — payments are exactly that shape. Derive
28
+ * `groups` and the table's `scopes` from ONE state-machine definition: two
29
+ * hand-maintained lists of the same states will drift, and the drift shows up as
30
+ * a column the tabs do not have.
31
+ */
32
+ export interface BoardConfig<T extends Record<string, unknown>> {
33
+ /** The row field whose value places a card in a column. */
34
+ groupBy: keyof T & string;
35
+ /** The columns, in DELIBERATE reading order — the board renders this order. */
36
+ groups: BoardGroup[];
37
+ /** Optional numeric row field summed per column (e.g. the amount in cents). */
38
+ sumBy?: keyof T & string;
39
+ /**
40
+ * Formats a column's total. The PAGE's own formatter, so the board prints
41
+ * money in the store's currency instead of inventing a second convention.
42
+ * Omitted ⇒ the raw sum.
43
+ */
44
+ formatSum?: (total: number) => string;
45
+ /** Header for the column collecting rows in states the front end does not declare. */
46
+ extraLabel?: string;
47
+ }
48
+
49
+ /** The default header for the catch-all column (see {@link groupRows}). */
50
+ const DEFAULT_EXTRA_LABEL = "Sem etapa";
51
+
52
+ /** One resolved column: a declared group (or the catch-all) plus its loaded rows. */
53
+ interface ResolvedColumn<T> {
54
+ key: string;
55
+ label: string;
56
+ tone?: string;
57
+ rows: T[];
58
+ }
59
+
60
+ /** Read the grouping value off a row as a string (`null`/`undefined` ⇒ ""). */
61
+ function groupValueOf<T extends Record<string, unknown>>(row: T, groupBy: string): string {
62
+ const value = row[groupBy];
63
+ return value === null || value === undefined ? "" : String(value);
64
+ }
65
+
66
+ /**
67
+ * Place every loaded row into a column, in the DECLARED order, and collect the
68
+ * leftovers into one extra column at the end.
69
+ *
70
+ * Nothing is ever dropped, and that is the point: a state added to the back end
71
+ * before the front end knows about it must not make rows disappear. On a
72
+ * payments board, a silently dropped row is money nobody can see. The extra
73
+ * column only exists when something landed in it — once every row is placed it
74
+ * is gone, so the common case shows exactly the declared columns.
75
+ *
76
+ * A declared group with no rows KEEPS its position: the order is a reading
77
+ * order chosen by the host, not a ranking derived from the data.
78
+ */
79
+ export function groupRows<T extends Record<string, unknown>>(
80
+ rows: T[],
81
+ board: BoardConfig<T>,
82
+ ): ResolvedColumn<T>[] {
83
+ const declared = new Map<string, T[]>(board.groups.map((group) => [group.value, []]));
84
+ const extra: T[] = [];
85
+ for (const row of rows) {
86
+ const bucket = declared.get(groupValueOf(row, board.groupBy));
87
+ if (bucket) bucket.push(row);
88
+ else extra.push(row);
89
+ }
90
+ const columns: ResolvedColumn<T>[] = board.groups.map((group) => ({
91
+ key: group.value,
92
+ label: group.label,
93
+ tone: group.tone,
94
+ rows: declared.get(group.value) ?? [],
95
+ }));
96
+ if (extra.length > 0) {
97
+ columns.push({ key: "__extra__", label: board.extraLabel ?? DEFAULT_EXTRA_LABEL, rows: extra });
98
+ }
99
+ return columns;
100
+ }
101
+
102
+ /** A column's total over the rows ON THIS PAGE, or null when nothing is summed. */
103
+ function sumOf<T extends Record<string, unknown>>(rows: T[], sumBy: string | undefined): number | null {
104
+ if (!sumBy) return null;
105
+ return rows.reduce((total, row) => {
106
+ const value = row[sumBy];
107
+ return typeof value === "number" && Number.isFinite(value) ? total + value : total;
108
+ }, 0);
109
+ }
110
+
111
+ /** Map a `tone` hint onto a theme colour, defaulting for anything unrecognised. */
112
+ function toneColor(tone: string | undefined): string {
113
+ const known = ["primary", "secondary", "success", "warning", "error", "info"];
114
+ return tone && known.includes(tone) ? `${tone}.main` : "divider";
115
+ }
116
+
117
+ /**
118
+ * A column's heading: the label, the page count and the optional sum.
119
+ *
120
+ * The count is labelled "nesta página" EVERYWHERE it appears, including in its
121
+ * accessible name. The scope tabs above the board show whole-query totals from
122
+ * the server; an unlabelled column count would contradict them the moment there
123
+ * is a second page, and the reader has no way to tell which number to believe.
124
+ */
125
+ function BoardColumnHeader({
126
+ label,
127
+ count,
128
+ sum,
129
+ testId,
130
+ }: {
131
+ label: string;
132
+ count: number;
133
+ /** The formatted total, or null when the board sums nothing. */
134
+ sum: string | null;
135
+ testId: string;
136
+ }): React.JSX.Element {
137
+ return (
138
+ <Box sx={{ display: "flex", flexDirection: "column", gap: 0.25 }}>
139
+ <Box sx={{ display: "flex", alignItems: "baseline", justifyContent: "space-between", gap: 1 }}>
140
+ <Text variant="caption" as="span">
141
+ <Box component="span" sx={{ fontWeight: 600 }}>
142
+ {label}
143
+ </Box>
144
+ </Text>
145
+ <Text variant="caption" as="span">
146
+ <Box
147
+ component="span"
148
+ data-testid={`${testId}-count`}
149
+ aria-label={`${label}: ${count} nesta página`}
150
+ sx={{ color: "text.secondary", whiteSpace: "nowrap" }}
151
+ >
152
+ {count} nesta página
153
+ </Box>
154
+ </Text>
155
+ </Box>
156
+ {sum !== null && (
157
+ <Text variant="caption" as="span">
158
+ <Box
159
+ component="span"
160
+ data-testid={`${testId}-sum`}
161
+ aria-label={`Soma desta página: ${sum}`}
162
+ sx={{ color: "text.secondary" }}
163
+ >
164
+ {sum}
165
+ </Box>
166
+ </Text>
167
+ )}
168
+ </Box>
169
+ );
170
+ }
171
+
172
+ interface BoardColumnProps<T extends Record<string, unknown>> {
173
+ column: ResolvedColumn<T>;
174
+ board: BoardConfig<T>;
175
+ getRowId: (row: T) => string | number;
176
+ renderCard: (row: T, selection: DataViewCardSelection) => React.ReactNode;
177
+ selectedIds: Set<string | number>;
178
+ onToggleId: (id: string | number) => void;
179
+ cardScale: number;
180
+ width: number;
181
+ testId: string;
182
+ }
183
+
184
+ /** One column: its header over a vertical stack of the entity's own cards. */
185
+ function BoardColumn<T extends Record<string, unknown>>({
186
+ column,
187
+ board,
188
+ getRowId,
189
+ renderCard,
190
+ selectedIds,
191
+ onToggleId,
192
+ cardScale,
193
+ width,
194
+ testId,
195
+ }: BoardColumnProps<T>): React.JSX.Element {
196
+ const total = sumOf(column.rows, board.sumBy);
197
+ return (
198
+ <Box
199
+ data-testid={testId}
200
+ sx={{
201
+ flex: `0 0 ${width}px`,
202
+ minWidth: width,
203
+ display: "flex",
204
+ flexDirection: "column",
205
+ gap: 1,
206
+ borderTop: 3,
207
+ borderStyle: "solid",
208
+ borderColor: toneColor(column.tone),
209
+ borderRadius: 1,
210
+ bgcolor: "action.hover",
211
+ p: 1,
212
+ }}
213
+ >
214
+ <BoardColumnHeader
215
+ label={column.label}
216
+ count={column.rows.length}
217
+ sum={total === null ? null : String(board.formatSum ? board.formatSum(total) : total)}
218
+ testId={testId}
219
+ />
220
+ {column.rows.length === 0 ? (
221
+ <Text variant="caption" as="p">
222
+ <Box component="span" data-testid={`${testId}-empty`} sx={{ color: "text.disabled" }}>
223
+ Nada nesta etapa
224
+ </Box>
225
+ </Text>
226
+ ) : (
227
+ column.rows.map((row) => {
228
+ const id = getRowId(row);
229
+ return (
230
+ <Box key={id}>
231
+ {renderCard(row, {
232
+ selected: selectedIds.has(id),
233
+ onToggleSelect: () => onToggleId(id),
234
+ scale: cardScale,
235
+ })}
236
+ </Box>
237
+ );
238
+ })
239
+ )}
240
+ </Box>
241
+ );
242
+ }
243
+
244
+ interface DataViewsBoardProps<T extends Record<string, unknown>> {
245
+ /** The LOADED page, verbatim. The board never filters, sorts or paginates. */
246
+ rows: T[];
247
+ board: BoardConfig<T>;
248
+ getRowId: (row: T) => string | number;
249
+ renderCard: (row: T, selection: DataViewCardSelection) => React.ReactNode;
250
+ selectedIds: Set<string | number>;
251
+ onToggleId: (id: string | number) => void;
252
+ /** Card size multiplier from the toolbar zoom slider — the same knob as cards. */
253
+ cardScale: number;
254
+ dataTestId?: string;
255
+ }
256
+
257
+ /** The board's column width at scale 1; the zoom slider multiplies it. */
258
+ const BASE_COLUMN_WIDTH = 240;
259
+ /** Never narrower than this, however far the zoom is wound down (touch targets). */
260
+ const MIN_COLUMN_WIDTH = 220;
261
+
262
+ /**
263
+ * The BOARD layout: the loaded page distributed into columns of the entity's own
264
+ * card. A rendering, not a second source of truth — switching to it emits no
265
+ * query, and it shows exactly the rows the table showed.
266
+ *
267
+ * The board scrolls HORIZONTALLY inside its own box rather than letting the page
268
+ * scroll sideways, and its columns keep a usable width at every zoom, so a phone
269
+ * gets a real board instead of six unreadable slivers.
270
+ *
271
+ * Cards are not draggable between columns. Dragging a payment from Recusado to
272
+ * Pago is a domain transition with side effects, not a re-render: it needs the
273
+ * allowed-transition set from the state machine plus a confirmation, and
274
+ * `RowAction.isVisible` is the existing per-row guard to reuse when that lands.
275
+ */
276
+ export function DataViewsBoard<T extends Record<string, unknown>>({
277
+ rows,
278
+ board,
279
+ getRowId,
280
+ renderCard,
281
+ selectedIds,
282
+ onToggleId,
283
+ cardScale,
284
+ dataTestId,
285
+ }: DataViewsBoardProps<T>): React.JSX.Element {
286
+ const columns = groupRows(rows, board);
287
+ const width = Math.max(MIN_COLUMN_WIDTH, Math.round(BASE_COLUMN_WIDTH * cardScale));
288
+ const testId = dataTestId ? `${dataTestId}-board` : "data-views-board";
289
+ return (
290
+ <Box sx={{ mt: 1.5 }}>
291
+ <Text variant="caption" as="p">
292
+ <Box component="span" data-testid={`${testId}-scale-note`} sx={{ color: "text.secondary" }}>
293
+ As contagens e somas abaixo referem-se apenas aos itens desta página.
294
+ </Box>
295
+ </Text>
296
+ <Box
297
+ data-testid={testId}
298
+ sx={{
299
+ mt: 1,
300
+ display: "flex",
301
+ alignItems: "flex-start",
302
+ gap: 1.5,
303
+ // The BOARD scrolls, not the page: without `maxWidth` the flex row
304
+ // widens its parent and the whole document scrolls sideways.
305
+ maxWidth: "100%",
306
+ overflowX: "auto",
307
+ pb: 1,
308
+ }}
309
+ >
310
+ {columns.map((column) => (
311
+ <BoardColumn
312
+ key={column.key}
313
+ column={column}
314
+ board={board}
315
+ getRowId={getRowId}
316
+ renderCard={renderCard}
317
+ selectedIds={selectedIds}
318
+ onToggleId={onToggleId}
319
+ cardScale={cardScale}
320
+ width={width}
321
+ testId={`${testId}-column-${column.key}`}
322
+ />
323
+ ))}
324
+ </Box>
325
+ </Box>
326
+ );
327
+ }
@@ -5,9 +5,14 @@ import { useEffect, useRef } from "react";
5
5
  import { type SortFieldDefinition } from "../../layout/ContentToolbar";
6
6
 
7
7
  import { GridShell } from "./data-views-grid-parts";
8
+ import type { DisplayPanelView } from "./data-views-display-panel";
9
+ import type { BoardConfig } from "./DataViewsBoard";
10
+ import type { DataViewExport } from "./data-views-export";
11
+ import type { ScopeConfig } from "./data-views-scopes";
8
12
  import type { DataViewCardSelection } from "./data-views-types";
9
13
  import {
10
14
  type DataViewColumn,
15
+ type DataViewsLayout,
11
16
  type DataViewServer,
12
17
  type DataViewState,
13
18
  type DataViewSyncState,
@@ -47,6 +52,12 @@ interface DataViewsGridProps<T extends Record<string, unknown>> {
47
52
  onVisibleRowsChange?: (rows: T[]) => void;
48
53
  /** Saved-views UI injected into the toolbar (FUT-89). */
49
54
  toolbarRightSlot?: React.ReactNode;
55
+ /**
56
+ * The saved-view chrome for the Exibir panel: its VISÃO header (with the
57
+ * views dropdown) and its Redefinir / Salvar visão footer. Supplied by
58
+ * `DataViewsTableBase`; without it the panel is tabs only.
59
+ */
60
+ displayView?: DisplayPanelView;
50
61
  /** Big page title in the header row (next to the funnel + gear). */
51
62
  title?: string;
52
63
  /** Primary page actions rendered at the header's right. */
@@ -56,6 +67,18 @@ interface DataViewsGridProps<T extends Record<string, unknown>> {
56
67
  * columns (asc/desc each) when omitted.
57
68
  */
58
69
  sortFields?: SortFieldDefinition[];
70
+ /**
71
+ * Per-sort-field VALUE KIND (`"currency" | "number" | "date" | "text"`), so the
72
+ * Exibir panel phrases the direction in that column's own terms. "Crescente"
73
+ * on a currency column is a puzzle; "Menor → maior" is not. Defaults to text.
74
+ */
75
+ sortKinds?: Record<string, string>;
76
+ /**
77
+ * Opt-in "Exportar" control. The grid hands the host the current query,
78
+ * UNPAGINATED, and the host re-queries — the grid never fetches, and an
79
+ * export therefore follows the filters rather than the loaded page.
80
+ */
81
+ exportConfig?: DataViewExport;
59
82
  /**
60
83
  * A single, reusable list of row actions that drives BOTH the per-row "⋮"
61
84
  * kebab AND the bulk-actions menu (tabwoah model). When provided, the grid
@@ -86,8 +109,36 @@ interface DataViewsGridProps<T extends Record<string, unknown>> {
86
109
  * (reusing the same search/filter/sort). Omit ⇒ table only (unchanged).
87
110
  */
88
111
  renderCard?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
89
- /** Which layout to show first when cards are available (default "table"). */
90
- defaultLayout?: "cards" | "table";
112
+ /**
113
+ * Opt-in "Quadro" (board) layout: the loaded page laid out as columns of one
114
+ * grouping field. Reuses `renderCard`, so supplying a board WITHOUT one simply
115
+ * offers no board. Best when the groups are states in a state machine — derive
116
+ * these groups and `scopes` from ONE definition so the two cannot drift.
117
+ */
118
+ board?: BoardConfig<T>;
119
+ /**
120
+ * Opt-in "Lista" layout (FUT-733): one FULL-WIDTH row per record, rendered by
121
+ * the entity — a marker, a title, a subtitle and a value on the right. Sits
122
+ * between the table (compare columns) and the cards (browse); omit ⇒ not
123
+ * offered, exactly as `renderCard` gates the cards.
124
+ */
125
+ renderListRow?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
126
+ /**
127
+ * The page-level partition rendered as an exclusive tab strip above the
128
+ * toolbar, with server-supplied counts. Requires `server`: a scope is applied
129
+ * at the backend, never in the browser.
130
+ */
131
+ scopes?: ScopeConfig[];
132
+ /** The row field the scopes partition by — used only to reject a pill over the same field. */
133
+ scopeFieldId?: string;
134
+ /** Which layout to show first when the user has expressed no preference (default "table"). */
135
+ defaultLayout?: DataViewsLayout;
136
+ /**
137
+ * Ignore the remembered cross-screen layout preference and pin to
138
+ * `defaultLayout`. For tables that exist to SHOW a layout — stories, docs,
139
+ * screenshots — not for real screens.
140
+ */
141
+ ignoreStoredLayout?: boolean;
91
142
  /**
92
143
  * Opt into the responsive inline filter UX (collapsible filter row on wide
93
144
  * screens, modal on narrow). Default `false` keeps the classic slide-in panel.
@@ -100,63 +151,17 @@ interface DataViewsGridProps<T extends Record<string, unknown>> {
100
151
  }
101
152
 
102
153
  /**
103
- * The reusable admin DataViews table (FUT-88): the shared ContentToolbar +
104
- * TableFilter panel over `@12-apps/ui` DataGrid. State + derived data live in
105
- * {@link useDataViewsState}; the render lives in `GridShell`. This component just
106
- * connects the two. Reused across every admin list page.
154
+ * Surface the visible (filtered) rows so a page-level Export matches the view.
155
+ *
156
+ * Pages pass inline column/field arrays, so `matched` is a fresh reference on
157
+ * every render even when its contents are unchanged. The guard is a signature
158
+ * over the rows' *contents* (not just their ids), so the callback — and the
159
+ * parent `useState` it usually drives — fires only when the visible data
160
+ * actually changes. Ids alone are not enough: a server refresh can change a
161
+ * visible row's values while keeping its id, and the export must reflect them.
162
+ * Content-equal renders keep the same signature, so this never loops.
107
163
  */
108
- export function DataViewsGrid<T extends Record<string, unknown>>({
109
- rows,
110
- columns,
111
- fields,
112
- rangeFields = [],
113
- getRowId,
114
- onRowClick,
115
- emptyState,
116
- dataTestId,
117
- testIdPrefix = "table",
118
- appliedState,
119
- syncState,
120
- onStateChange,
121
- onVisibleRowsChange,
122
- toolbarRightSlot,
123
- sortFields,
124
- rowActions,
125
- rowActionsLeading,
126
- bulkActions,
127
- renderRowMenu,
128
- renderCard,
129
- defaultLayout,
130
- inlineFilters,
131
- alwaysShowSearch,
132
- server,
133
- }: DataViewsGridProps<T>): React.JSX.Element {
134
- const controller = useDataViewsState({
135
- rows,
136
- columns,
137
- fields,
138
- rangeFields,
139
- getRowId,
140
- onRowClick,
141
- appliedState,
142
- syncState,
143
- onStateChange,
144
- testIdPrefix,
145
- sortFields,
146
- rowActions,
147
- rowActionsLeading,
148
- renderRowMenu,
149
- server,
150
- });
151
-
152
- // Surface the visible (filtered) rows so a page-level Export matches the view. Pages pass
153
- // inline column/field arrays, so `matched` is a fresh reference on every render even when
154
- // its contents are unchanged. Guard on a signature over the rows' *contents* (not just ids)
155
- // so we only notify — and trigger the parent's setState — when the visible data actually
156
- // changes. Ids alone are not enough: a server refresh can change a visible row's values
157
- // while keeping its id, and the export must reflect those new values. Content-equal renders
158
- // keep the same signature, so the effect + a parent `useState` never loop.
159
- const { matched } = controller;
164
+ function useVisibleRowsNotifier<T>(matched: T[], onVisibleRowsChange?: (rows: T[]) => void): void {
160
165
  const visibleSignature = JSON.stringify(matched);
161
166
  const lastSignature = useRef<string | null>(null);
162
167
  useEffect(() => {
@@ -164,24 +169,22 @@ export function DataViewsGrid<T extends Record<string, unknown>>({
164
169
  lastSignature.current = visibleSignature;
165
170
  onVisibleRowsChange?.(matched);
166
171
  }, [visibleSignature, matched, onVisibleRowsChange]);
172
+ }
167
173
 
168
- return (
169
- <GridShell
170
- c={controller}
171
- rows={rows}
172
- fields={fields}
173
- rangeFields={rangeFields}
174
- getRowId={getRowId}
175
- testIdPrefix={testIdPrefix}
176
- dataTestId={dataTestId}
177
- emptyState={emptyState}
178
- toolbarRightSlot={toolbarRightSlot}
179
- rowActions={rowActions}
180
- bulkActions={bulkActions}
181
- renderCard={renderCard}
182
- defaultLayout={defaultLayout}
183
- inlineFilters={inlineFilters}
184
- alwaysShowSearch={alwaysShowSearch}
185
- />
186
- );
174
+ /**
175
+ * The reusable admin DataViews table (FUT-88): the shared ContentToolbar +
176
+ * TableFilter panel over `@12-apps/ui` DataGrid. State + derived data live in
177
+ * {@link useDataViewsState}; the render lives in `GridShell`. This component just
178
+ * connects the two. Reused across every admin list page.
179
+ */
180
+ export function DataViewsGrid<T extends Record<string, unknown>>(
181
+ props: DataViewsGridProps<T>,
182
+ ): React.JSX.Element {
183
+ // Forwarded as a whole rather than restated name-by-name twice: this component
184
+ // only ADDS the controller, and a 25-name destructure repeated in the JSX is
185
+ // where a newly added prop silently stops being passed on.
186
+ const { rangeFields = [], testIdPrefix = "table" } = props;
187
+ const controller = useDataViewsState({ ...props, rangeFields, testIdPrefix });
188
+ useVisibleRowsNotifier(controller.matched, props.onVisibleRowsChange);
189
+ return <GridShell {...props} c={controller} rangeFields={rangeFields} testIdPrefix={testIdPrefix} />;
187
190
  }