@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
@@ -1,5 +1,8 @@
1
1
  "use client";
2
2
 
3
+ import GroupOutlinedIcon from "@mui/icons-material/GroupOutlined";
4
+ import PushPinOutlinedIcon from "@mui/icons-material/PushPinOutlined";
5
+ import StarOutlineRoundedIcon from "@mui/icons-material/StarOutlineRounded";
3
6
  import { useState } from "react";
4
7
 
5
8
  import { Dialog, DialogContent } from "../../feedback/Dialog";
@@ -40,29 +43,6 @@ interface SaveViewModalProps<T extends Record<string, unknown>> {
40
43
  testIdPrefix?: string;
41
44
  }
42
45
 
43
- function PreviewList({ title, items, testId }: { title: string; items: string[]; testId: string }): React.JSX.Element {
44
- return (
45
- <Box data-testid={testId}>
46
- <Text variant="caption" as="span" weight="semibold">
47
- {title}
48
- </Text>
49
- {items.length === 0 ? (
50
- <Text variant="caption" as="p" color="secondary">
51
- —
52
- </Text>
53
- ) : (
54
- <Stack component="ul" spacing={0.25} sx={{ m: 0, pl: 2 }}>
55
- {items.map((item) => (
56
- <Text key={item} variant="caption" as="li">
57
- {item}
58
- </Text>
59
- ))}
60
- </Stack>
61
- )}
62
- </Box>
63
- );
64
- }
65
-
66
46
  interface FlagsSwitchesProps {
67
47
  pinned: boolean;
68
48
  shared: boolean;
@@ -73,6 +53,63 @@ interface FlagsSwitchesProps {
73
53
  testIdPrefix: string;
74
54
  }
75
55
 
56
+ /**
57
+ * One flag: what it is, and what it DOES. A bare "Fixar na barra lateral"
58
+ * beside a switch asks the operator to guess where it appears and to whom —
59
+ * the sentence is the difference between a labelled toggle and an explained one.
60
+ */
61
+ function FlagRow({
62
+ icon,
63
+ title,
64
+ description,
65
+ checked,
66
+ onChange,
67
+ testId,
68
+ }: {
69
+ icon: React.ReactNode;
70
+ title: string;
71
+ description: string;
72
+ checked: boolean;
73
+ onChange: (value: boolean) => void;
74
+ testId: string;
75
+ }): React.JSX.Element {
76
+ return (
77
+ <Box
78
+ component="label"
79
+ sx={{
80
+ display: "flex",
81
+ alignItems: "flex-start",
82
+ gap: 1.5,
83
+ px: 1,
84
+ py: 1,
85
+ borderRadius: 1,
86
+ cursor: "pointer",
87
+ "&:hover": { bgcolor: "action.hover" },
88
+ }}
89
+ >
90
+ <Box sx={{ mt: 0.25, color: "text.disabled", display: "flex" }}>{icon}</Box>
91
+ <Box sx={{ minWidth: 0, flex: 1 }}>
92
+ <Text variant="body" as="span">
93
+ <Box component="span" sx={{ display: "block", fontSize: "0.875rem" }}>
94
+ {title}
95
+ </Box>
96
+ </Text>
97
+ <Text variant="caption" as="span">
98
+ <Box component="span" sx={{ display: "block", color: "text.secondary", lineHeight: 1.5 }}>
99
+ {description}
100
+ </Box>
101
+ </Text>
102
+ </Box>
103
+ <Switch
104
+ size="sm"
105
+ checked={checked}
106
+ onChange={(_event, value) => onChange(value)}
107
+ dataTestId={testId}
108
+ />
109
+ </Box>
110
+ );
111
+ }
112
+
76
113
  function FlagsSwitches({
77
114
  pinned,
78
115
  shared,
@@ -83,27 +120,30 @@ function FlagsSwitches({
83
120
  testIdPrefix,
84
121
  }: FlagsSwitchesProps): React.JSX.Element {
85
122
  return (
86
- <Stack spacing={0.5}>
87
- <Switch
88
- size="sm"
89
- label="Fixar na barra lateral"
123
+ <Stack spacing={0}>
124
+ <FlagRow
125
+ icon={<PushPinOutlinedIcon fontSize="small" />}
126
+ title="Fixar na barra lateral"
127
+ description="Aparece como atalho no menu, abaixo desta tela."
90
128
  checked={pinned}
91
- onChange={(_event, value) => setPinned(value)}
92
- dataTestId={`${testIdPrefix}-save-pinned`}
129
+ onChange={setPinned}
130
+ testId={`${testIdPrefix}-save-pinned`}
93
131
  />
94
- <Switch
95
- size="sm"
96
- label="Compartilhar com a equipe"
132
+ <FlagRow
133
+ icon={<GroupOutlinedIcon fontSize="small" />}
134
+ title="Compartilhar com a equipe"
135
+ description="Qualquer pessoa da loja poderá abrir e usar esta visão."
97
136
  checked={shared}
98
- onChange={(_event, value) => setShared(value)}
99
- dataTestId={`${testIdPrefix}-save-shared`}
137
+ onChange={setShared}
138
+ testId={`${testIdPrefix}-save-shared`}
100
139
  />
101
- <Switch
102
- size="sm"
103
- label="Definir como padrão"
140
+ <FlagRow
141
+ icon={<StarOutlineRoundedIcon fontSize="small" />}
142
+ title="Definir como padrão"
143
+ description="Esta tela abre nesta visão em vez da Visão principal."
104
144
  checked={isDefault}
105
- onChange={(_event, value) => setIsDefault(value)}
106
- dataTestId={`${testIdPrefix}-save-default`}
145
+ onChange={setIsDefault}
146
+ testId={`${testIdPrefix}-save-default`}
107
147
  />
108
148
  </Stack>
109
149
  );
@@ -114,17 +154,61 @@ interface PreviewBoxProps {
114
154
  testIdPrefix: string;
115
155
  }
116
156
 
157
+ /** One "label — value" line of the summary. */
158
+ function SummaryRow({
159
+ label,
160
+ items,
161
+ testId,
162
+ }: {
163
+ label: string;
164
+ items: string[];
165
+ testId: string;
166
+ }): React.JSX.Element | null {
167
+ if (items.length === 0) return null;
168
+ return (
169
+ <Box sx={{ display: "flex", gap: 1.5 }} data-testid={testId}>
170
+ <Text variant="caption" as="span">
171
+ <Box component="span" sx={{ width: 116, flexShrink: 0, color: "text.secondary" }}>
172
+ {label}
173
+ </Box>
174
+ </Text>
175
+ <Text variant="caption" as="span">
176
+ <Box component="span" sx={{ minWidth: 0, flex: 1 }}>
177
+ {items.join(", ")}
178
+ </Box>
179
+ </Text>
180
+ </Box>
181
+ );
182
+ }
183
+
117
184
  function PreviewBox({ preview, testIdPrefix }: PreviewBoxProps): React.JSX.Element {
185
+ const empty =
186
+ preview.filters.length === 0 && preview.columns.length === 0 && preview.sort.length === 0;
118
187
  return (
119
- <Box sx={{ p: 1.5, borderRadius: 1, border: (theme) => `1px solid ${theme.palette.divider}` }}>
120
- <Text variant="caption" as="p" weight="semibold">
121
- Prévia
188
+ <Box sx={{ p: 1.5, borderRadius: 1.5, bgcolor: "action.hover", border: (theme) => `1px solid ${theme.palette.divider}` }}>
189
+ <Text variant="caption" as="p">
190
+ <Box
191
+ component="span"
192
+ sx={{ display: "block", mb: 1, textTransform: "uppercase", letterSpacing: 0.6, fontWeight: 600, color: "text.disabled" }}
193
+ >
194
+ O que esta visão guarda
195
+ </Box>
122
196
  </Text>
123
- <Stack spacing={1} sx={{ mt: 0.5 }}>
124
- <PreviewList title="Filtros" items={preview.filters} testId={`${testIdPrefix}-preview-filters`} />
125
- <PreviewList title="Colunas" items={preview.columns} testId={`${testIdPrefix}-preview-columns`} />
126
- <PreviewList title="Ordenação" items={preview.sort} testId={`${testIdPrefix}-preview-sort`} />
127
- </Stack>
197
+ {empty ? (
198
+ // Saving is still allowed — an operator may be naming the default on
199
+ // purpose — but it should not be a surprise afterwards.
200
+ <Text variant="caption" as="p">
201
+ <Box component="span" sx={{ color: "warning.main", lineHeight: 1.6 }} data-testid={`${testIdPrefix}-preview-empty`}>
202
+ Nada foi alterado — esta visão ficará igual à Visão principal.
203
+ </Box>
204
+ </Text>
205
+ ) : (
206
+ <Stack spacing={0.75}>
207
+ <SummaryRow label="Filtros" items={preview.filters} testId={`${testIdPrefix}-preview-filters`} />
208
+ <SummaryRow label="Colunas ocultas" items={preview.columns} testId={`${testIdPrefix}-preview-columns`} />
209
+ <SummaryRow label="Ordenação" items={preview.sort} testId={`${testIdPrefix}-preview-sort`} />
210
+ </Stack>
211
+ )}
128
212
  </Box>
129
213
  );
130
214
  }
@@ -239,6 +323,7 @@ export function SaveViewModal<T extends Record<string, unknown>>({
239
323
  size="sm"
240
324
  fullWidth
241
325
  label="Nome"
326
+ placeholder="Ex.: Recusados no PIX desta semana"
242
327
  value={form.name}
243
328
  onChange={(event) => form.setName(event.target.value)}
244
329
  data-testid={`${testIdPrefix}-save-name`}
@@ -246,7 +331,8 @@ export function SaveViewModal<T extends Record<string, unknown>>({
246
331
  <Input
247
332
  size="sm"
248
333
  fullWidth
249
- label="Descrição (opcional)"
334
+ label="Descrição — opcional"
335
+ placeholder="Para que serve esta visão"
250
336
  value={form.description}
251
337
  onChange={(event) => form.setDescription(event.target.value)}
252
338
  data-testid={`${testIdPrefix}-save-description`}
@@ -270,7 +356,12 @@ export function SaveViewModal<T extends Record<string, unknown>>({
270
356
  </Text>
271
357
  )}
272
358
 
273
- <Stack direction="row" spacing={2}>
359
+ {/* Cancel first, primary last: the confirming action sits where the
360
+ eye lands at the end of the dialog, not before the way out. */}
361
+ <Stack direction="row" spacing={1.5} sx={{ justifyContent: "flex-end" }}>
362
+ <Button variant="text" color="neutral" onClick={onClose} dataTestId={`${testIdPrefix}-save-cancel`}>
363
+ Cancelar
364
+ </Button>
274
365
  <Button
275
366
  onClick={() => void form.submit()}
276
367
  disabled={!form.canSave}
@@ -278,9 +369,6 @@ export function SaveViewModal<T extends Record<string, unknown>>({
278
369
  >
279
370
  {editing ? "Salvar alterações" : "Salvar visão"}
280
371
  </Button>
281
- <Button variant="text" onClick={onClose} dataTestId={`${testIdPrefix}-save-cancel`}>
282
- Cancelar
283
- </Button>
284
372
  </Stack>
285
373
  </Stack>
286
374
  </DialogContent>
@@ -0,0 +1,240 @@
1
+ "use client";
2
+
3
+ import ArrowDownwardRoundedIcon from "@mui/icons-material/ArrowDownwardRounded";
4
+ import ArrowUpwardRoundedIcon from "@mui/icons-material/ArrowUpwardRounded";
5
+ import DragIndicatorRoundedIcon from "@mui/icons-material/DragIndicatorRounded";
6
+ import { Checkbox } from "@mui/material";
7
+ import { useState } from "react";
8
+
9
+ import { Box } from "../../../mui/Box";
10
+ import { Text } from "../../typography/Text";
11
+
12
+ import type { HideableColumn } from "./data-views-columns";
13
+
14
+ /* ── Colunas ─────────────────────────────────────────────────────────────── */
15
+
16
+ interface ColumnsTabProps {
17
+ columns: HideableColumn[];
18
+ /** Every column id in the CURRENT order (including non-hideable ones). */
19
+ order: string[];
20
+ visibleColumns: string[];
21
+ onToggle: (id: string, visible: boolean) => void;
22
+ onReorder: (order: string[]) => void;
23
+ onShowAll: () => void;
24
+ onReset: () => void;
25
+ testIdPrefix: string;
26
+ }
27
+
28
+ /** Swap the column at `id` with its neighbour `delta` places away. */
29
+ function moved(order: string[], id: string, delta: number): string[] {
30
+ const from = order.indexOf(id);
31
+ const to = from + delta;
32
+ if (from < 0 || to < 0 || to >= order.length) return order;
33
+ const next = [...order];
34
+ [next[from], next[to]] = [next[to]!, next[from]!];
35
+ return next;
36
+ }
37
+
38
+ /**
39
+ * Visibility AND order in one list, because they are the same decision: an
40
+ * operator reordering columns is deciding what to read first, and hiding one is
41
+ * deciding not to read it at all. Drag the grip, or use the arrows — the arrows
42
+ * exist because drag-and-drop is unusable by keyboard and awkward on a phone.
43
+ */
44
+ export function ColumnsTab({
45
+ columns,
46
+ order,
47
+ visibleColumns,
48
+ onToggle,
49
+ onReorder,
50
+ onShowAll,
51
+ onReset,
52
+ testIdPrefix,
53
+ }: ColumnsTabProps): React.JSX.Element {
54
+ const [dragId, setDragId] = useState<string | null>(null);
55
+ const byId = new Map(columns.map((column) => [column.id, column]));
56
+ const ordered = order.map((id) => byId.get(id)).filter((column): column is HideableColumn => Boolean(column));
57
+ // Counted against `columns`, which is the HIDEABLE set — `visibleColumns`
58
+ // also carries the locked ones (a `hideable: false` id like the row's
59
+ // identifier), and counting it whole reads "9 de 8 visíveis".
60
+ const visibleHideableCount = visibleColumns.filter((id) => byId.has(id)).length;
61
+
62
+ const dropOn = (id: string): void => {
63
+ if (!dragId || dragId === id) return;
64
+ const without = order.filter((key) => key !== dragId);
65
+ without.splice(without.indexOf(id), 0, dragId);
66
+ onReorder(without);
67
+ setDragId(null);
68
+ };
69
+
70
+ return (
71
+ <Box>
72
+ <Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", px: 1.5, py: 1 }}>
73
+ <Text variant="caption" as="span">
74
+ <Box component="span" sx={{ color: "text.secondary" }} data-testid={`${testIdPrefix}-columns-count`}>
75
+ <strong>{visibleHideableCount}</strong> de {columns.length} visíveis
76
+ </Box>
77
+ </Text>
78
+ <Box sx={{ display: "flex", gap: 1 }}>
79
+ <LinkButton label="Mostrar todas" onClick={onShowAll} testId={`${testIdPrefix}-columns-show-all`} />
80
+ <LinkButton label="Padrão" onClick={onReset} testId={`${testIdPrefix}-columns-reset`} muted />
81
+ </Box>
82
+ </Box>
83
+ <Text variant="caption" as="p">
84
+ <Box component="span" sx={{ px: 1.5, pb: 0.5, display: "block", color: "text.disabled" }}>
85
+ Arraste para reordenar as colunas.
86
+ </Box>
87
+ </Text>
88
+ <Box sx={{ px: 0.75, pb: 0.75 }}>
89
+ {ordered.map((column) => (
90
+ <ColumnRow
91
+ key={column.id}
92
+ column={column}
93
+ visible={visibleColumns.includes(column.id)}
94
+ dragging={dragId === column.id}
95
+ onToggle={onToggle}
96
+ onDragStart={() => setDragId(column.id)}
97
+ onDragEnd={() => setDragId(null)}
98
+ onDrop={() => dropOn(column.id)}
99
+ onMove={(delta) => onReorder(moved(order, column.id, delta))}
100
+ testIdPrefix={testIdPrefix}
101
+ />
102
+ ))}
103
+ </Box>
104
+ </Box>
105
+ );
106
+ }
107
+
108
+ /** A borderless text action, for the two column resets. */
109
+ function LinkButton({
110
+ label,
111
+ onClick,
112
+ testId,
113
+ muted,
114
+ }: {
115
+ label: string;
116
+ onClick: () => void;
117
+ testId: string;
118
+ muted?: boolean;
119
+ }): React.JSX.Element {
120
+ return (
121
+ <Box
122
+ component="button"
123
+ type="button"
124
+ onClick={onClick}
125
+ data-testid={testId}
126
+ sx={{
127
+ border: 0,
128
+ background: "none",
129
+ cursor: "pointer",
130
+ font: "inherit",
131
+ fontSize: "0.75rem",
132
+ p: 0,
133
+ color: muted ? "text.secondary" : "primary.main",
134
+ "&:hover": { textDecoration: "underline" },
135
+ }}
136
+ >
137
+ {label}
138
+ </Box>
139
+ );
140
+ }
141
+
142
+ interface ColumnRowProps {
143
+ column: HideableColumn;
144
+ visible: boolean;
145
+ dragging: boolean;
146
+ onToggle: (id: string, visible: boolean) => void;
147
+ onDragStart: () => void;
148
+ onDragEnd: () => void;
149
+ onDrop: () => void;
150
+ onMove: (delta: number) => void;
151
+ testIdPrefix: string;
152
+ }
153
+
154
+ /** One column: grip, checkbox, label, and the keyboard-reachable move arrows. */
155
+ function ColumnRow({
156
+ column,
157
+ visible,
158
+ dragging,
159
+ onToggle,
160
+ onDragStart,
161
+ onDragEnd,
162
+ onDrop,
163
+ onMove,
164
+ testIdPrefix,
165
+ }: ColumnRowProps): React.JSX.Element {
166
+ return (
167
+ <Box
168
+ draggable
169
+ onDragStart={onDragStart}
170
+ onDragEnd={onDragEnd}
171
+ onDragOver={(event: React.DragEvent) => event.preventDefault()}
172
+ onDrop={onDrop}
173
+ data-testid={`${testIdPrefix}-column-row-${column.id}`}
174
+ sx={{
175
+ display: "flex",
176
+ alignItems: "center",
177
+ gap: 0.5,
178
+ px: 0.75,
179
+ py: 0.25,
180
+ borderRadius: 1,
181
+ opacity: dragging ? 0.4 : 1,
182
+ "&:hover": { bgcolor: "action.hover" },
183
+ "&:hover .dv-col-move": { opacity: 1 },
184
+ }}
185
+ >
186
+ <Box component="span" aria-hidden sx={{ display: "flex", color: "action.disabled", cursor: "grab" }}>
187
+ <DragIndicatorRoundedIcon fontSize="small" />
188
+ </Box>
189
+ <Checkbox
190
+ size="small"
191
+ checked={visible}
192
+ onChange={(event) => onToggle(column.id, event.target.checked)}
193
+ inputProps={{ "aria-label": column.label }}
194
+ data-testid={`${testIdPrefix}-column-toggle-${column.id}`}
195
+ />
196
+ <Text variant="caption" as="span">
197
+ <Box component="span" sx={{ flex: 1, color: visible ? "text.primary" : "text.disabled" }}>
198
+ {column.label}
199
+ </Box>
200
+ </Text>
201
+ <Box className="dv-col-move" sx={{ ml: "auto", display: "flex", opacity: 0, transition: "opacity .15s" }}>
202
+ <MoveButton label={`Mover ${column.label} para cima`} onClick={() => onMove(-1)} up />
203
+ <MoveButton label={`Mover ${column.label} para baixo`} onClick={() => onMove(1)} />
204
+ </Box>
205
+ </Box>
206
+ );
207
+ }
208
+
209
+ /** One of the two reorder arrows — the keyboard's path through the drag handle. */
210
+ function MoveButton({
211
+ label,
212
+ onClick,
213
+ up,
214
+ }: {
215
+ label: string;
216
+ onClick: () => void;
217
+ up?: boolean;
218
+ }): React.JSX.Element {
219
+ return (
220
+ <Box
221
+ component="button"
222
+ type="button"
223
+ aria-label={label}
224
+ onClick={onClick}
225
+ sx={{
226
+ display: "flex",
227
+ border: 0,
228
+ p: 0.25,
229
+ background: "none",
230
+ borderRadius: 0.5,
231
+ cursor: "pointer",
232
+ color: "text.secondary",
233
+ "&:hover": { bgcolor: "action.selected" },
234
+ }}
235
+ >
236
+ {up ? <ArrowUpwardRoundedIcon sx={{ fontSize: 14 }} /> : <ArrowDownwardRoundedIcon sx={{ fontSize: 14 }} />}
237
+ </Box>
238
+ );
239
+ }
240
+
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+
5
+ import { type GridColumn } from "../DataGrid";
6
+
7
+ import {
8
+ buildGridColumns,
9
+ buildRowActionsColumn,
10
+ isActionsColumn,
11
+ } from "./data-views-grid-helpers";
12
+ import type { DataViewColumn, RowAction } from "./data-views-types";
13
+
14
+ /**
15
+ * Every column derivation the DataViews grid needs, in one place: the auto kebab
16
+ * actions column, the id list that seeds a pristine view state, what the grid
17
+ * actually renders once visibility is applied, and the show/hide menu's list.
18
+ */
19
+
20
+ /** A column the user may show/hide via the toolbar's ColumnsMenu. */
21
+ export interface HideableColumn {
22
+ id: string;
23
+ label: string;
24
+ }
25
+
26
+ /**
27
+ * The columns the user may show/hide: explicitly hideable (not `hideable:false`,
28
+ * which marks structural columns like the image thumbnail or actions kebab) and
29
+ * carrying a text header to label the checkbox.
30
+ */
31
+ function computeHideableColumns<T extends Record<string, unknown>>(
32
+ columns: DataViewColumn<T>[],
33
+ ): HideableColumn[] {
34
+ return columns
35
+ .filter((col) => col.hideable !== false && typeof col.header === "string" && col.header !== "")
36
+ .map((col) => ({ id: col.id, label: col.header as string }));
37
+ }
38
+
39
+ /**
40
+ * Append the auto kebab actions column unless the page already defines one. The
41
+ * column is added when there are `rowActions` OR a bespoke `renderRowMenu` (an
42
+ * entity's self-contained menu that owns its own popups).
43
+ */
44
+ function appendActionsColumn<T extends Record<string, unknown>>(
45
+ columns: DataViewColumn<T>[],
46
+ opts: {
47
+ rowActions?: RowAction<T>[];
48
+ rowActionsLeading?: (row: T) => React.ReactNode;
49
+ renderRowMenu?: (row: T) => React.ReactNode;
50
+ testIdPrefix: string;
51
+ getRowId: (row: T) => string | number;
52
+ },
53
+ ): DataViewColumn<T>[] {
54
+ const { rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId } = opts;
55
+ if ((!rowActions && !renderRowMenu) || columns.some(isActionsColumn)) return columns;
56
+ return [
57
+ ...columns,
58
+ buildRowActionsColumn({
59
+ rowActions: rowActions ?? [],
60
+ leading: rowActionsLeading,
61
+ testIdPrefix,
62
+ getRowId,
63
+ renderRowMenu,
64
+ }),
65
+ ];
66
+ }
67
+
68
+ /** Options {@link appendActionsColumn} needs to build the auto kebab column. */
69
+ type ActionsColumnOpts<T extends Record<string, unknown>> = {
70
+ rowActions?: RowAction<T>[];
71
+ rowActionsLeading?: (row: T) => React.ReactNode;
72
+ renderRowMenu?: (row: T) => React.ReactNode;
73
+ testIdPrefix: string;
74
+ getRowId: (row: T) => string | number;
75
+ };
76
+
77
+ /**
78
+ * The declared columns plus the auto kebab column (unless the page defines its
79
+ * own), and every column id — which is what seeds a pristine view state's
80
+ * `visibleColumns`, so it is derived BEFORE the state exists.
81
+ */
82
+ export function useColumnsWithActions<T extends Record<string, unknown>>(
83
+ columns: DataViewColumn<T>[],
84
+ opts: ActionsColumnOpts<T>,
85
+ ): { columnsWithActions: DataViewColumn<T>[]; allColumnIds: string[] } {
86
+ const { rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId } = opts;
87
+ const columnsWithActions = useMemo(
88
+ () =>
89
+ appendActionsColumn(columns, { rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId }),
90
+ [columns, rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId],
91
+ );
92
+ const allColumnIds = useMemo(() => columnsWithActions.map((col) => col.id), [columnsWithActions]);
93
+ return { columnsWithActions, allColumnIds };
94
+ }
95
+
96
+ /**
97
+ * Apply a stored column ORDER, tolerantly.
98
+ *
99
+ * A stored list goes stale in both directions and neither may lose a column: an
100
+ * id the table no longer declares is dropped, and a column the table declares
101
+ * but the list never mentioned (added since the view was saved) falls in AFTER
102
+ * the ordered ones rather than vanishing. Absent order ⇒ declaration order.
103
+ */
104
+ function applyOrder<T extends Record<string, unknown>>(
105
+ columns: DataViewColumn<T>[],
106
+ order: string[] | undefined,
107
+ ): DataViewColumn<T>[] {
108
+ if (!order || order.length === 0) return columns;
109
+ const byId = new Map(columns.map((column) => [column.id, column]));
110
+ const ordered = order.map((id) => byId.get(id)).filter((c): c is DataViewColumn<T> => Boolean(c));
111
+ const seen = new Set(ordered.map((column) => column.id));
112
+ return [...ordered, ...columns.filter((column) => !seen.has(column.id))];
113
+ }
114
+
115
+ /** What the grid actually renders: order + visibility applied, plus the menu's list. */
116
+ export function useRenderedColumns<T extends Record<string, unknown>>(
117
+ columnsWithActions: DataViewColumn<T>[],
118
+ visibleColumns: string[],
119
+ onRowClick: ((row: T) => void) | undefined,
120
+ order?: string[],
121
+ ): { gridColumns: GridColumn<T>[]; hideableColumns: HideableColumn[] } {
122
+ const gridColumns = useMemo(
123
+ () => buildGridColumns(applyOrder(columnsWithActions, order), visibleColumns, onRowClick),
124
+ [columnsWithActions, onRowClick, visibleColumns, order],
125
+ );
126
+ const hideableColumns = useMemo(() => computeHideableColumns(columnsWithActions), [columnsWithActions]);
127
+ return { gridColumns, hideableColumns };
128
+ }
129
+
@@ -0,0 +1,62 @@
1
+ import type { DataViewState, RangeValue } from "./data-views-types";
2
+
3
+ /**
4
+ * A comparable key for a view state — the answer to "has anything changed?".
5
+ *
6
+ * `JSON.stringify(state)` is not that answer. Clearing a filter does not remove
7
+ * its entry, it leaves an EMPTY one (`pills: { pagamento: [] }`), and an emptied
8
+ * range leaves `{}` behind; both stringify differently from the state that never
9
+ * had them, so a filter applied and then removed left the view reading "unsaved
10
+ * changes" over a state identical to the one it started from. The only way back
11
+ * was "Redefinir", which is the operator undoing something they had already
12
+ * undone.
13
+ *
14
+ * So: drop what is empty, sort what is unordered, and keep what actually
15
+ * distinguishes one view from another.
16
+ */
17
+
18
+ /** Is this range bounded at either end? An empty one is not a constraint. */
19
+ function isSet(range: RangeValue | undefined): boolean {
20
+ return Boolean(range && (range.min !== undefined || range.max !== undefined));
21
+ }
22
+
23
+ /** Selected values, sorted — picking A then B is the same view as B then A. */
24
+ function normalizePills(pills: Record<string, string[]> | undefined): Record<string, string[]> {
25
+ const out: Record<string, string[]> = {};
26
+ for (const key of Object.keys(pills ?? {}).sort()) {
27
+ const values = pills?.[key];
28
+ if (values && values.length > 0) out[key] = [...values].sort();
29
+ }
30
+ return out;
31
+ }
32
+
33
+ function normalizeRanges(
34
+ ranges: Record<string, RangeValue> | undefined,
35
+ ): Record<string, RangeValue> {
36
+ const out: Record<string, RangeValue> = {};
37
+ for (const key of Object.keys(ranges ?? {}).sort()) {
38
+ const range = ranges?.[key];
39
+ if (isSet(range)) out[key] = range as RangeValue;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /**
45
+ * Everything a saved view stores, in a stable shape.
46
+ *
47
+ * `visibleColumns` is SORTED — it is a set, and its array order carries no
48
+ * meaning (reading order lives in `order`). `order` is not sorted, for the
49
+ * opposite reason.
50
+ */
51
+ export function viewStateKey(state: DataViewState): string {
52
+ return JSON.stringify({
53
+ search: state.search.trim(),
54
+ pills: normalizePills(state.pills),
55
+ ranges: normalizeRanges(state.ranges),
56
+ sortBy: state.sortBy ?? [],
57
+ visibleColumns: [...(state.visibleColumns ?? [])].sort(),
58
+ order: state.order ?? null,
59
+ scope: state.scope ?? null,
60
+ layout: state.layout ?? null,
61
+ });
62
+ }