@12-apps/ui 1.5.1 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/package.json +5 -5
  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
@@ -1,15 +1,23 @@
1
1
  "use client";
2
2
 
3
3
  import type { SortFieldDefinition } from "../../layout/ContentToolbar";
4
- import { useMemo, useRef, useState } from "react";
4
+ import { useEffect, useMemo, useRef, useState } from "react";
5
5
 
6
6
  import { DataViewsGrid } from "./DataViewsGrid";
7
- import { ViewDialogs, ViewMutationErrorAlert, ViewsMenuSlot } from "./data-views-table-parts";
8
- import { useViewMutations, type ViewMutations } from "./data-views-view-mutations";
7
+ import type { DisplayPanelView } from "./data-views-display-panel";
8
+ import { viewStateKey } from "./data-views-dirty";
9
+ import { DeleteViewDialog } from "./DeleteViewDialog";
10
+ import type { BoardConfig } from "./DataViewsBoard";
11
+ import type { DataViewExport } from "./data-views-export";
12
+ import type { ScopeConfig } from "./data-views-scopes";
13
+ import { ViewDialogs, ViewMutationErrorAlert } from "./data-views-table-parts";
14
+ import {
15
+ useSavedViewsController,
16
+ type SavedViewsController,
17
+ } from "./use-saved-views-controller";
9
18
  import {
10
- coerceViewState,
11
- emptyViewState,
12
19
  type DataViewColumn,
20
+ type DataViewsLayout,
13
21
  type DataViewServer,
14
22
  type DataViewState,
15
23
  type DataViewSyncState,
@@ -21,7 +29,6 @@ import {
21
29
  type RowAction,
22
30
  type SavedViewSummary,
23
31
  } from "./data-views-types";
24
- import { type SaveViewPayload } from "./SaveViewModal";
25
32
 
26
33
  export interface DataViewsTableBaseProps<T extends Record<string, unknown>> {
27
34
  /** The applied-view id read from the URL (`?view=`) on load, or null. */
@@ -48,6 +55,18 @@ export interface DataViewsTableBaseProps<T extends Record<string, unknown>> {
48
55
  headerActions?: React.ReactNode;
49
56
  /** Sort options for the toolbar's "Sort By" dropdown (defaults to sortable columns). */
50
57
  sortFields?: SortFieldDefinition[];
58
+ /**
59
+ * Per-sort-field VALUE KIND (`"currency" | "number" | "date" | "text"`), so the
60
+ * Exibir panel phrases the direction in that column's own terms. "Crescente"
61
+ * on a currency column is a puzzle; "Menor → maior" is not. Defaults to text.
62
+ */
63
+ sortKinds?: Record<string, string>;
64
+ /**
65
+ * Opt-in "Exportar" control. The grid hands the host the current query,
66
+ * UNPAGINATED, and the host re-queries — the grid never fetches, and an
67
+ * export therefore follows the filters rather than the loaded page.
68
+ */
69
+ exportConfig?: DataViewExport;
51
70
  /** Notified with the currently-visible (filtered) rows, so Export matches the view. */
52
71
  onVisibleRowsChange?: (rows: T[]) => void;
53
72
  /** Reusable row actions driving both the row "⋮" kebab and the bulk menu. */
@@ -60,8 +79,22 @@ export interface DataViewsTableBaseProps<T extends Record<string, unknown>> {
60
79
  renderRowMenu?: (row: T) => React.ReactNode;
61
80
  /** Opt-in "Grade" (cards) layout — a card renderer per row; adds a Grade/Tabela toggle. */
62
81
  renderCard?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
63
- /** Which layout to show first when cards are available (default "table"). */
64
- defaultLayout?: "cards" | "table";
82
+ /** Opt-in "Quadro" (board) layout — the loaded page as columns of one field. Needs `renderCard`. */
83
+ board?: BoardConfig<T>;
84
+ /** Opt-in "Lista" layout — one full-width, entity-rendered row per record (FUT-733). */
85
+ renderListRow?: (row: T, selection: DataViewCardSelection) => React.ReactNode;
86
+ /** The page-level partition rendered as tabs above the toolbar (server mode only). */
87
+ scopes?: ScopeConfig[];
88
+ /** The row field the scopes partition by — used only to reject a pill over the same field. */
89
+ scopeFieldId?: string;
90
+ /** Which layout to show first when the user has expressed no preference (default "table"). */
91
+ defaultLayout?: DataViewsLayout;
92
+ /**
93
+ * Ignore the remembered cross-screen layout preference and pin to
94
+ * `defaultLayout`. For tables that exist to SHOW a layout — stories, docs,
95
+ * screenshots — not for real screens.
96
+ */
97
+ ignoreStoredLayout?: boolean;
65
98
  /** Opt into the responsive inline filter UX (collapsible row / modal). Default keeps the panel. */
66
99
  inlineFilters?: boolean;
67
100
  /** Keep the inline search visible with no filter fields and no active search. */
@@ -92,6 +125,12 @@ function useUrlSyncState(
92
125
  pills: initialState.pills,
93
126
  ranges: initialState.ranges,
94
127
  sortBy: initialState.sortBy,
128
+ // The scope belongs in the URL-driven slice alongside them: a
129
+ // `?view=recusados`-style deep link and browser back/forward must
130
+ // move the tab strip, and they must do it WITHOUT resetting the
131
+ // user's hidden columns — which is the whole reason this is a
132
+ // merging `syncState` and not a replacing `appliedState`.
133
+ scope: initialState.scope,
95
134
  }
96
135
  : undefined,
97
136
  [isServerMode, initialState],
@@ -110,188 +149,96 @@ function resolveInitialView(
110
149
  return requested ?? views.find((view) => view.isDefault);
111
150
  }
112
151
 
113
- interface SavedViewsController {
114
- applied: DataViewState;
115
- /** Name of the currently-applied saved view, if any (drives the trigger label). */
116
- activeViewName?: string;
117
- /** Reset to the built-in no-filter "Main vision" default (clears the URL param). */
118
- selectMain: () => void;
119
- currentRef: React.MutableRefObject<DataViewState>;
120
- saveOpen: boolean;
121
- editing: SavedViewSummary | null;
122
- manageOpen: boolean;
123
- setSaveOpen: (open: boolean) => void;
124
- setEditing: (view: SavedViewSummary | null) => void;
125
- setManageOpen: (open: boolean) => void;
126
- applyView: (view: SavedViewSummary) => void;
127
- openCreate: () => void;
128
- openEdit: (view: SavedViewSummary) => void;
129
- /** Close the save modal and clear the editing target. */
130
- closeSave: () => void;
131
- /** Close the manage dialog and open the edit modal for `view`. */
132
- editFromManage: (view: SavedViewSummary) => void;
133
- patchView: ViewMutations["patchView"];
134
- handleDelete: ViewMutations["handleDelete"];
135
- handleSave: (payload: SaveViewPayload) => Promise<void>;
136
- /** Last pin/share/default/delete failure — shown as an Alert, never silent. */
137
- mutationError: string | null;
138
- clearMutationError: () => void;
139
- }
140
-
141
- interface AppliedView {
142
- applied: DataViewState;
143
- currentRef: React.MutableRefObject<DataViewState>;
144
- activeViewName?: string;
145
- applyView: (view: SavedViewSummary) => void;
146
- selectMain: () => void;
147
- }
148
-
149
152
  /**
150
- * The applied grid state + which saved view it came from, kept in sync with the
151
- * URL (`?view=<id>`) so a refresh restores it. "Main vision" resets + clears it.
153
+ * The Exibir panel's view chrome, assembled from the saved-views controller.
154
+ * Extracted so {@link DataViewsTableBase} stays inside its size budget.
152
155
  */
153
- function useAppliedView(
154
- router: DataViewRouter,
155
- columnIds: string[],
156
- views: SavedViewSummary[],
157
- initialView: SavedViewSummary | undefined,
158
- initialState: DataViewState | undefined,
159
- ): AppliedView {
160
- // A saved view wins; otherwise seed from the host-supplied initial state (e.g.
161
- // filters/sort read off the URL, FUT-180) so a bookmarked filtered URL is
162
- // reflected in the grid — column visibility isn't URL-carried, so show all.
163
- const seed = initialView
164
- ? coerceViewState(initialView.state, columnIds)
165
- : initialState
166
- ? { ...initialState, visibleColumns: columnIds }
167
- : emptyViewState(columnIds);
168
- const [applied, setApplied] = useState<DataViewState>(seed);
169
- const [appliedViewId, setAppliedViewId] = useState<string | null>(initialView?.id ?? null);
170
- const currentRef = useRef<DataViewState>(seed);
171
-
172
- const applyView = (view: SavedViewSummary): void => {
173
- setApplied(coerceViewState(view.state, columnIds));
174
- setAppliedViewId(view.id);
175
- router.syncViewParam(view.id);
176
- };
177
- const selectMain = (): void => {
178
- setApplied(emptyViewState(columnIds));
179
- setAppliedViewId(null);
180
- router.syncViewParam(null);
156
+ function buildDisplayView({
157
+ ctl,
158
+ views,
159
+ activeView,
160
+ dirty,
161
+ onRequestDelete,
162
+ }: {
163
+ ctl: SavedViewsController;
164
+ views: SavedViewSummary[];
165
+ activeView: SavedViewSummary | undefined;
166
+ dirty: boolean;
167
+ /** Opens the confirmation; the delete itself happens on confirm. */
168
+ onRequestDelete: (view: SavedViewSummary) => void;
169
+ }): DisplayPanelView {
170
+ return {
171
+ activeViewName: ctl.activeViewName,
172
+ dirty,
173
+ onReset: () => (activeView ? ctl.applyView(activeView) : ctl.selectMain()),
174
+ // Only offer "Atualizar" when there IS a view to update — otherwise the
175
+ // single footer button is a plain "Salvar visão".
176
+ onUpdate: activeView ? () => ctl.openEdit(activeView) : undefined,
177
+ onSaveAs: ctl.openCreate,
178
+ // The views themselves, not a dropdown: the panel renders them in its own
179
+ // body (see `data-views-view-nav`), so there is no second popover to clip
180
+ // the row actions.
181
+ nav: {
182
+ views,
183
+ activeViewId: activeView?.id ?? null,
184
+ onSelectView: (id) => {
185
+ const next = id === null ? null : views.find((view) => view.id === id);
186
+ if (next) ctl.applyView(next);
187
+ else ctl.selectMain();
188
+ },
189
+ onEditView: ctl.openEdit,
190
+ onPatchView: (view, changes) => void ctl.patchView(view, changes),
191
+ onDeleteView: onRequestDelete,
192
+ },
181
193
  };
182
- const activeViewName = views.find((view) => view.id === appliedViewId)?.name;
183
- return { applied, currentRef, activeViewName, applyView, selectMain };
184
194
  }
185
195
 
186
196
  /**
187
- * Owns the saved-views state + persistence for {@link DataViewsTable}: the applied
188
- * grid state, the live-state ref, the modal/dialog flags, and the create/edit/
189
- * delete/patch handlers. Extracted so the component render stays within budget.
197
+ * "Are there unsaved changes?" — the dot on Exibir, and whether Redefinir and
198
+ * Salvar visão are live.
199
+ *
200
+ * Two things it gets right that the inline version did not. It compares
201
+ * NORMALISED states (`viewStateKey`), so a filter applied and then removed is
202
+ * clean again rather than clean-looking but flagged; and it re-baselines after
203
+ * a SAVE, because the state just written is the clean one from that point.
204
+ *
205
+ * The baseline is the first state the grid emits for a view, not the view
206
+ * itself: the grid normalises what it is handed — resolving visible columns,
207
+ * filling in layout — so the applied view is never byte-identical to the state
208
+ * it produces.
190
209
  */
191
- function useSavedViewsController(
192
- persistence: DataViewPersistence,
193
- router: DataViewRouter,
194
- columnIds: string[],
195
- views: SavedViewSummary[],
196
- initialView: SavedViewSummary | undefined,
197
- initialState: DataViewState | undefined,
198
- ): SavedViewsController {
199
- const { applied, currentRef, activeViewName, applyView, selectMain } =
200
- useAppliedView(router, columnIds, views, initialView, initialState);
201
- const [saveOpen, setSaveOpen] = useState(false);
202
- const [editing, setEditing] = useState<SavedViewSummary | null>(null);
203
- const [manageOpen, setManageOpen] = useState(false);
204
-
205
- const openCreate = (): void => {
206
- setEditing(null);
207
- setSaveOpen(true);
208
- };
209
- const openEdit = (view: SavedViewSummary): void => {
210
- setEditing(view);
211
- setSaveOpen(true);
212
- };
213
-
214
- const [mutationError, setMutationError] = useState<string | null>(null);
215
- const refreshAfter = (ok: boolean, error?: string): void => {
216
- if (ok) {
217
- setMutationError(null);
218
- router.refresh();
219
- } else {
220
- // A rejected pin/share/default/delete must be visible (FUT-100) — the
221
- // menus close on click, so an inline Alert is the only surviving signal.
222
- setMutationError(error ?? "Não foi possível salvar a alteração da visão.");
223
- }
224
- };
210
+ function useDirtyTracking(ctl: SavedViewsController): {
211
+ dirty: boolean;
212
+ noteState: (state: DataViewState) => void;
213
+ markSaved: () => void;
214
+ } {
215
+ const [dirty, setDirty] = useState(false);
216
+ const [savedAt, setSavedAt] = useState(0);
217
+ const baseline = useRef<string | null>(null);
218
+ const appliedKey = JSON.stringify(ctl.applied ?? null);
225
219
 
226
- const { handleSave: saveView, patchView, handleDelete } = useViewMutations(
227
- persistence,
228
- editing,
229
- currentRef,
230
- refreshAfter,
231
- );
232
-
233
- const handleSave = async (payload: SaveViewPayload): Promise<void> => {
234
- await saveView(payload);
235
- setSaveOpen(false);
236
- setEditing(null);
237
- };
220
+ useEffect(() => {
221
+ baseline.current = null;
222
+ setDirty(false);
223
+ }, [appliedKey, savedAt]);
238
224
 
239
225
  return {
240
- applied,
241
- activeViewName,
242
- selectMain,
243
- currentRef,
244
- saveOpen,
245
- editing,
246
- manageOpen,
247
- setSaveOpen,
248
- setEditing,
249
- setManageOpen,
250
- applyView,
251
- openCreate,
252
- openEdit,
253
- closeSave: () => {
254
- setSaveOpen(false);
255
- setEditing(null);
256
- },
257
- editFromManage: (view: SavedViewSummary) => {
258
- setManageOpen(false);
259
- openEdit(view);
226
+ dirty,
227
+ noteState: (state) => {
228
+ ctl.currentRef.current = state;
229
+ const key = viewStateKey(state);
230
+ if (baseline.current === null) {
231
+ baseline.current = key;
232
+ setDirty(false);
233
+ return;
234
+ }
235
+ const next = key !== baseline.current;
236
+ setDirty((prev) => (prev === next ? prev : next));
260
237
  },
261
- patchView,
262
- handleDelete,
263
- handleSave,
264
- mutationError,
265
- clearMutationError: () => setMutationError(null),
238
+ markSaved: () => setSavedAt((tick) => tick + 1),
266
239
  };
267
240
  }
268
241
 
269
- /**
270
- * The saved-views dropdown wired to the controller — the toolbar's right slot.
271
- * Extracted so the {@link DataViewsTable} render stays within the size budget.
272
- */
273
- function ViewsMenu(props: {
274
- views: SavedViewSummary[];
275
- ctl: SavedViewsController;
276
- testIdPrefix: string;
277
- }): React.JSX.Element {
278
- const { views, ctl, testIdPrefix } = props;
279
- return (
280
- <ViewsMenuSlot
281
- views={views}
282
- activeViewName={ctl.activeViewName}
283
- applyView={ctl.applyView}
284
- selectMain={ctl.selectMain}
285
- openCreate={ctl.openCreate}
286
- openEdit={ctl.openEdit}
287
- handleDelete={ctl.handleDelete}
288
- patchView={ctl.patchView}
289
- onManageAll={() => ctl.setManageOpen(true)}
290
- testIdPrefix={testIdPrefix}
291
- />
292
- );
293
- }
294
-
295
242
  /**
296
243
  * The reusable admin table (FUT-89): the DataViews grid plus the full saved-views
297
244
  * experience — a views dropdown, a save/edit modal (with preview), and a manage
@@ -301,38 +248,43 @@ function ViewsMenu(props: {
301
248
  * a view restores its filters + columns + sort. Each table supplies its own columns
302
249
  * + filter fields; everything else is shared.
303
250
  */
304
- export function DataViewsTableBase<T extends Record<string, unknown>>({
305
- initialViewId = null,
306
- persistence,
307
- router,
308
- rows,
309
- columns,
310
- fields,
311
- rangeFields,
312
- getRowId,
313
- onRowClick,
314
- emptyState,
315
- dataTestId,
316
- testIdPrefix = "table",
317
- views,
318
- title,
319
- headerActions,
320
- sortFields,
321
- onVisibleRowsChange,
322
- rowActions,
323
- rowActionsLeading,
324
- bulkActions,
325
- renderRowMenu,
326
- renderCard,
327
- defaultLayout,
328
- inlineFilters,
329
- alwaysShowSearch,
330
- server,
331
- initialState,
332
- }: DataViewsTableBaseProps<T>): React.JSX.Element {
251
+ export function DataViewsTableBase<T extends Record<string, unknown>>(
252
+ props: DataViewsTableBaseProps<T>,
253
+ ): React.JSX.Element {
254
+ // Forwarded as a whole rather than restated name-by-name twice (as in
255
+ // DataViewsGrid): this component only ADDS the saved-views chrome, and a
256
+ // 30-name destructure repeated in the JSX is exactly where a newly added prop
257
+ // silently stops being passed on.
258
+ const {
259
+ initialViewId = null,
260
+ persistence,
261
+ router,
262
+ columns,
263
+ fields,
264
+ testIdPrefix = "table",
265
+ views,
266
+ server,
267
+ initialState,
268
+ } = props;
333
269
  const initialView = resolveInitialView(views, initialViewId);
334
270
  const columnIds = columns.map((col) => col.id);
335
271
  const ctl = useSavedViewsController(persistence, router, columnIds, views, initialView, initialState);
272
+ // Unsaved-changes flag for the Exibir panel. Set from `onStateChange` rather
273
+ // than derived in render because the live state lives in a REF (the grid owns
274
+ // it); the equality bail-out in `setDirty` is what stops the update loop.
275
+ const { dirty, noteState, markSaved } = useDirtyTracking(ctl);
276
+ // Deleting a saved view is confirmed, not immediate — see `DeleteViewDialog`.
277
+ const [pendingDelete, setPendingDelete] = useState<SavedViewSummary | null>(null);
278
+ // The view the panel's "Redefinir" restores: the applied one, or the built-in
279
+ // "Visão principal" when none is.
280
+ const activeView = views.find((view) => view.name === ctl.activeViewName);
281
+ const displayView = buildDisplayView({
282
+ ctl,
283
+ views,
284
+ activeView,
285
+ dirty,
286
+ onRequestDelete: setPendingDelete,
287
+ });
336
288
  // Server-mode lists re-apply URL-derived controls on back/forward (not just the mount seed).
337
289
  const syncState = useUrlSyncState(server, initialState);
338
290
 
@@ -340,34 +292,25 @@ export function DataViewsTableBase<T extends Record<string, unknown>>({
340
292
  <>
341
293
  <ViewMutationErrorAlert error={ctl.mutationError} onClose={ctl.clearMutationError} testIdPrefix={testIdPrefix} />
342
294
  <DataViewsGrid<T>
343
- rows={rows}
344
- columns={columns}
345
- fields={fields}
346
- rangeFields={rangeFields}
347
- getRowId={getRowId}
348
- onRowClick={onRowClick}
349
- emptyState={emptyState}
350
- dataTestId={dataTestId}
295
+ {...props}
351
296
  testIdPrefix={testIdPrefix}
352
- title={title}
353
- headerActions={headerActions}
354
- sortFields={sortFields}
355
- rowActions={rowActions}
356
- rowActionsLeading={rowActionsLeading}
357
- bulkActions={bulkActions}
358
- renderRowMenu={renderRowMenu}
359
- renderCard={renderCard}
360
- defaultLayout={defaultLayout}
361
- inlineFilters={inlineFilters}
362
- alwaysShowSearch={alwaysShowSearch}
363
- server={server}
364
297
  appliedState={ctl.applied}
365
298
  syncState={syncState}
366
- onStateChange={(state) => (ctl.currentRef.current = state)}
367
- onVisibleRowsChange={onVisibleRowsChange}
368
- toolbarRightSlot={<ViewsMenu views={views} ctl={ctl} testIdPrefix={testIdPrefix} />}
299
+ onStateChange={noteState}
300
+ // The saved-views menu is NOT a toolbar control any more: it lives in
301
+ // the Exibir panel's VISÃO header, passed through `displayView`.
302
+ displayView={displayView}
303
+ />
304
+ {renderViewDialogs(ctl, fields, columns, views, testIdPrefix, markSaved)}
305
+ <DeleteViewDialog
306
+ view={pendingDelete}
307
+ onClose={() => setPendingDelete(null)}
308
+ onConfirm={(view) => {
309
+ void ctl.handleDelete(view);
310
+ setPendingDelete(null);
311
+ }}
312
+ testIdPrefix={testIdPrefix}
369
313
  />
370
- {renderViewDialogs(ctl, fields, columns, views, testIdPrefix)}
371
314
  </>
372
315
  );
373
316
  }
@@ -379,6 +322,7 @@ function renderViewDialogs<T extends Record<string, unknown>>(
379
322
  columns: DataViewsTableBaseProps<T>["columns"],
380
323
  views: SavedViewSummary[],
381
324
  testIdPrefix: string,
325
+ onSaved: () => void,
382
326
  ): React.JSX.Element {
383
327
  return (
384
328
  <ViewDialogs<T>
@@ -388,7 +332,11 @@ function renderViewDialogs<T extends Record<string, unknown>>(
388
332
  fields={fields}
389
333
  columns={columns}
390
334
  editing={ctl.editing}
391
- onSave={ctl.handleSave}
335
+ onSave={async (payload) => {
336
+ await ctl.handleSave(payload);
337
+ // The saved state is the clean one from here on.
338
+ onSaved();
339
+ }}
392
340
  manageOpen={ctl.manageOpen}
393
341
  onCloseManage={() => ctl.setManageOpen(false)}
394
342
  views={views}
@@ -0,0 +1,83 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The confirmation in front of "Excluir visão".
5
+ *
6
+ * Deleting was immediate: one click in a row menu, on a control sitting
7
+ * directly under four harmless toggles, and the view was gone. A saved view is
8
+ * somebody's filters, columns and sort — cheap to rebuild only if you remember
9
+ * what they were.
10
+ *
11
+ * It names the view rather than saying "this view", says plainly that the
12
+ * records are untouched, and warns separately when the view is SHARED, because
13
+ * that deletion takes it from everyone rather than from one person.
14
+ */
15
+ import { Dialog, DialogContent } from "../../feedback/Dialog";
16
+ import { Button } from "../../form/Button";
17
+ import { Stack } from "../../../mui/Stack";
18
+ import { Text } from "../../typography/Text";
19
+
20
+ import type { SavedViewSummary } from "./data-views-types";
21
+
22
+ export function DeleteViewDialog({
23
+ view,
24
+ onClose,
25
+ onConfirm,
26
+ /** What this table holds, for the "your records are safe" line. */
27
+ entityLabel = "Os registros",
28
+ testIdPrefix = "view",
29
+ }: {
30
+ view: SavedViewSummary | null;
31
+ onClose: () => void;
32
+ onConfirm: (view: SavedViewSummary) => void;
33
+ entityLabel?: string;
34
+ testIdPrefix?: string;
35
+ }): React.JSX.Element | null {
36
+ if (!view) return null;
37
+ return (
38
+ <Dialog
39
+ open
40
+ onClose={onClose}
41
+ title="Excluir visão"
42
+ size="sm"
43
+ showCloseButton
44
+ dataTestId={`${testIdPrefix}-delete-modal`}
45
+ >
46
+ <DialogContent>
47
+ <Stack spacing={1.5}>
48
+ <Text variant="body" as="p" data-testid={`${testIdPrefix}-delete-target`}>
49
+ Excluir <strong>{view.name}</strong>?
50
+ </Text>
51
+
52
+ {view.shared && (
53
+ <Text variant="body" color="warning" as="p" data-testid={`${testIdPrefix}-delete-shared`}>
54
+ Esta visão está compartilhada — a equipe perderá o acesso a ela.
55
+ </Text>
56
+ )}
57
+
58
+ <Text variant="body" color="secondary" as="p">
59
+ {entityLabel} não são afetados; apenas o recorte salvo é removido.
60
+ </Text>
61
+
62
+ <Stack direction="row" spacing={1.5} sx={{ justifyContent: "flex-end", pt: 0.5 }}>
63
+ <Button
64
+ variant="text"
65
+ color="neutral"
66
+ onClick={onClose}
67
+ dataTestId={`${testIdPrefix}-delete-cancel`}
68
+ >
69
+ Cancelar
70
+ </Button>
71
+ <Button
72
+ color="danger"
73
+ onClick={() => onConfirm(view)}
74
+ dataTestId={`${testIdPrefix}-delete-confirm`}
75
+ >
76
+ Excluir visão
77
+ </Button>
78
+ </Stack>
79
+ </Stack>
80
+ </DialogContent>
81
+ </Dialog>
82
+ );
83
+ }