@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.
- package/package.json +4 -4
- package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
- package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
- package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
- package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
- package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
- package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
- package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
- package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
- package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
- package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
- package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
- package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
- package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
- package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
- package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
- package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
- package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
- package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
- package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
- package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
- package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
- package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
- package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
- package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
- package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
- package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
- package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
- package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
- package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
- package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
- package/src/components/data-display/DataViews/data-views-types.ts +88 -9
- package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
- package/src/components/data-display/DataViews/index.ts +14 -1
- package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
- package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
- package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
- package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
- package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
- package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
- package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
- package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
- package/src/components/utility/InstallPrompt/index.ts +8 -0
- 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.
|
|
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.
|
|
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.
|
|
213
|
-
"@12-apps/typescript-config": "^1.
|
|
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
|
-
/**
|
|
90
|
-
|
|
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
|
-
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
}
|