@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
|
@@ -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
|
|
87
|
-
<
|
|
88
|
-
|
|
89
|
-
|
|
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={
|
|
92
|
-
|
|
129
|
+
onChange={setPinned}
|
|
130
|
+
testId={`${testIdPrefix}-save-pinned`}
|
|
93
131
|
/>
|
|
94
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
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={
|
|
99
|
-
|
|
137
|
+
onChange={setShared}
|
|
138
|
+
testId={`${testIdPrefix}-save-shared`}
|
|
100
139
|
/>
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
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={
|
|
106
|
-
|
|
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"
|
|
121
|
-
|
|
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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|