@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
@@ -7,12 +7,14 @@ import { type SortFieldDefinition } from "../../layout/ContentToolbar";
7
7
 
8
8
  import { filterRows } from "./data-views-filter";
9
9
  import {
10
- buildGridColumns,
11
- buildRowActionsColumn,
12
- computeActiveFilterCount,
13
- defaultSortFields,
14
- isActionsColumn,
15
- } from "./data-views-grid-helpers";
10
+ useColumnsWithActions,
11
+ useRenderedColumns,
12
+ type HideableColumn,
13
+ } from "./data-views-columns";
14
+ import { computeActiveFilterCount, defaultSortFields } from "./data-views-grid-helpers";
15
+ import { resolveScope, useScopeConfigChecks, type ScopeConfig } from "./data-views-scopes";
16
+ import { serverDerived, useServerQuery } from "./data-views-server-query";
17
+ import { useSelection } from "./data-views-selection";
16
18
  import {
17
19
  emptyViewState,
18
20
  type DataViewColumn,
@@ -21,10 +23,13 @@ import {
21
23
  type DataViewSyncState,
22
24
  type FilterFieldConfig,
23
25
  type RangeFieldConfig,
26
+ type DataViewQuery,
24
27
  type RangeValue,
25
28
  type RowAction,
26
29
  } from "./data-views-types";
27
30
 
31
+ export type { HideableColumn };
32
+
28
33
  /** Inputs the hook needs to own the view state + derived data. */
29
34
  interface UseDataViewsStateArgs<T extends Record<string, unknown>> {
30
35
  rows: T[];
@@ -52,12 +57,16 @@ interface UseDataViewsStateArgs<T extends Record<string, unknown>> {
52
57
  * `server.onQueryChange`. Omit for the legacy client-side behaviour.
53
58
  */
54
59
  server?: DataViewServer;
55
- }
56
-
57
- /** A column the user may show/hide via the toolbar's ColumnsMenu. */
58
- export interface HideableColumn {
59
- id: string;
60
- label: string;
60
+ /**
61
+ * The page-level partition rendered as tabs above the toolbar. Requires server
62
+ * mode: a scope is applied at the BACKEND, never in the browser.
63
+ */
64
+ scopes?: ScopeConfig[];
65
+ /**
66
+ * The row field the scopes partition by, when the host knows it — used only to
67
+ * reject the pill/scope clash at setup. See `assertNoScopePillOverlap`.
68
+ */
69
+ scopeFieldId?: string;
61
70
  }
62
71
 
63
72
  /** Everything the DataViewsGrid render needs; keeps the component itself thin. */
@@ -69,10 +78,18 @@ export interface DataViewsController<T extends Record<string, unknown>> {
69
78
  gridColumns: GridColumn<T>[];
70
79
  /** Columns eligible for the show/hide menu (hideable, with a text header). */
71
80
  hideableColumns: HideableColumn[];
81
+ /** Every column id in reading order — what the Colunas tab reorders. */
82
+ columnOrder: string[];
72
83
  /** Toggle one column's visibility in the current view state. */
73
84
  toggleColumn: (id: string, visible: boolean) => void;
74
85
  selectedIds: Set<string | number>;
75
86
  setSelectedIds: (ids: Set<string | number>) => void;
87
+ /**
88
+ * Add/remove ONE id from the selection. The card and board bodies both need
89
+ * it, so it lives here rather than being written out twice at the call sites —
90
+ * one selection model, one toggle, identical in every layout.
91
+ */
92
+ toggleId: (id: string | number) => void;
76
93
  selectedRows: T[];
77
94
  clearSelection: () => void;
78
95
  selectAll: () => void;
@@ -92,72 +109,26 @@ export interface DataViewsController<T extends Record<string, unknown>> {
92
109
  serverTotalCount: number;
93
110
  /** Server-mode: request a specific 1-based page (re-fetches via onQueryChange). */
94
111
  changePage: (page: number) => void;
95
- }
96
-
97
- /**
98
- * The columns the user may show/hide: explicitly hideable (not `hideable:false`,
99
- * which marks structural columns like the image thumbnail or actions kebab) and
100
- * carrying a text header to label the checkbox.
101
- */
102
- function computeHideableColumns<T extends Record<string, unknown>>(
103
- columns: DataViewColumn<T>[],
104
- ): HideableColumn[] {
105
- return columns
106
- .filter((col) => col.hideable !== false && typeof col.header === "string" && col.header !== "")
107
- .map((col) => ({ id: col.id, label: col.header as string }));
108
- }
109
-
110
- /**
111
- * Server-mode wiring extracted from {@link useDataViewsState} (keeps it within
112
- * the size/complexity budget): re-fetch on every effective-query change (page
113
- * resets to 1) and expose `changePage` for the pagination control. Read the
114
- * latest `state`/`server` at fire time so an inline `server` object per render
115
- * doesn't loop the effect.
116
- */
117
- function useServerQuery(server: DataViewServer | undefined, state: DataViewState): {
118
- changePage: (page: number) => void;
119
- } {
120
- const emit = (page: number): void => {
121
- if (!server) return;
122
- server.onQueryChange({
123
- search: state.search,
124
- pills: state.pills,
125
- ranges: state.ranges ?? {},
126
- sortBy: state.sortBy,
127
- page,
128
- pageSize: server.pageSize,
129
- });
130
- };
131
- const queryKey = server
132
- ? JSON.stringify({ q: state.search, pills: state.pills, ranges: state.ranges ?? {}, sort: state.sortBy })
133
- : "";
134
- const firstQuery = useRef(true);
135
- useEffect(() => {
136
- if (!server) return;
137
- if (firstQuery.current) {
138
- firstQuery.current = false;
139
- return;
140
- }
141
- emit(1);
142
- // Emit only when the query key changes; `emit` reads fresh state at fire time.
143
- }, [queryKey]);
144
- return { changePage: (page: number) => emit(page) };
145
- }
146
-
147
- /** The controller's server-mode derived fields (or client-mode defaults). */
148
- function serverDerived(
149
- server: DataViewServer | undefined,
150
- fallbackCount: number,
151
- ): { serverMode: boolean; serverPage: number; serverPageCount: number; serverTotalCount: number } {
152
- if (!server) {
153
- return { serverMode: false, serverPage: 1, serverPageCount: 1, serverTotalCount: fallbackCount };
154
- }
155
- return {
156
- serverMode: true,
157
- serverPage: server.page,
158
- serverPageCount: Math.max(1, Math.ceil(server.totalCount / server.pageSize)),
159
- serverTotalCount: server.totalCount,
160
- };
112
+ /**
113
+ * The active scope id, RESOLVED against the declared scopes (see
114
+ * `resolveScope`). `undefined` when the table declares none — which is what
115
+ * keeps `scope` out of the emitted query for every table that never opted in.
116
+ */
117
+ scope: string | undefined;
118
+ /** Select a scope: re-fetches from page 1 and drops the selection. */
119
+ setScope: (id: string) => void;
120
+ /**
121
+ * The query the grid is currently showing. Read by Export, which hands it
122
+ * back to the host UNPAGINATED — so an export follows the filters rather
123
+ * than the 25 rows that happen to be loaded.
124
+ */
125
+ currentQuery: DataViewQuery;
126
+ /**
127
+ * Server-supplied per-scope totals, passed through verbatim, or `undefined`
128
+ * when the server omits them. NEVER falls back to counting `matched`: a count
129
+ * off the loaded page is wrong under pagination, which is the bug scopes fix.
130
+ */
131
+ scopeCounts?: Record<string, number>;
161
132
  }
162
133
 
163
134
  /**
@@ -236,32 +207,83 @@ function useStateSyncEffects(
236
207
  }
237
208
 
238
209
  /**
239
- * Append the auto kebab actions column unless the page already defines one. The
240
- * column is added when there are `rowActions` OR a bespoke `renderRowMenu` (an
241
- * entity's self-contained menu that owns its own popups).
210
+ * The seed view state: a saved view (`appliedState`) or a pristine all-columns
211
+ * one, with the URL-driven controls (`syncState`) merged over it so a deep-linked
212
+ * filter or scope is reflected on the FIRST render. Column visibility is never
213
+ * URL-carried, so the merge cannot wipe it.
242
214
  */
243
- function appendActionsColumn<T extends Record<string, unknown>>(
244
- columns: DataViewColumn<T>[],
245
- opts: {
246
- rowActions?: RowAction<T>[];
247
- rowActionsLeading?: (row: T) => React.ReactNode;
248
- renderRowMenu?: (row: T) => React.ReactNode;
249
- testIdPrefix: string;
250
- getRowId: (row: T) => string | number;
251
- },
252
- ): DataViewColumn<T>[] {
253
- const { rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId } = opts;
254
- if ((!rowActions && !renderRowMenu) || columns.some(isActionsColumn)) return columns;
255
- return [
256
- ...columns,
257
- buildRowActionsColumn({
258
- rowActions: rowActions ?? [],
259
- leading: rowActionsLeading,
260
- testIdPrefix,
261
- getRowId,
262
- renderRowMenu,
263
- }),
264
- ];
215
+ function seedState(
216
+ appliedState: DataViewState | undefined,
217
+ syncState: DataViewSyncState | undefined,
218
+ allColumnIds: string[],
219
+ ): DataViewState {
220
+ const seed = appliedState ?? emptyViewState(allColumnIds);
221
+ return syncState ? { ...seed, ...syncState } : seed;
222
+ }
223
+
224
+ /**
225
+ * Selecting a scope: record it, then DROP the selection.
226
+ *
227
+ * A scope change renders a different result set. A selection that survived it
228
+ * would let a bulk action run against rows the user can no longer see — the
229
+ * same reason changing any other part of the query clears it.
230
+ */
231
+ function makeSetScope(
232
+ patch: (next: Partial<DataViewState>) => void,
233
+ clearSelection: () => void,
234
+ ): (id: string) => void {
235
+ return (id) => {
236
+ patch({ scope: id });
237
+ clearSelection();
238
+ };
239
+ }
240
+
241
+ /**
242
+ * The declared column ids in the stored reading order — the same tolerance
243
+ * `applyOrder` uses, so the Colunas tab lists exactly what the grid renders.
244
+ */
245
+ function orderedColumnIds(allColumnIds: string[], order: string[] | undefined): string[] {
246
+ if (!order || order.length === 0) return allColumnIds;
247
+ const declared = new Set(allColumnIds);
248
+ const kept = order.filter((id) => declared.has(id));
249
+ const seen = new Set(kept);
250
+ return [...kept, ...allColumnIds.filter((id) => !seen.has(id))];
251
+ }
252
+
253
+ /**
254
+ * The query the grid is currently SHOWING — the same shape `onQueryChange`
255
+ * emits, kept as a value so a host control (Exportar) can re-run it against the
256
+ * backend rather than reading the loaded rows.
257
+ */
258
+ function liveQuery(
259
+ state: DataViewState,
260
+ scope: string | undefined,
261
+ server: DataViewServer | undefined,
262
+ matchedCount: number,
263
+ ): DataViewQuery {
264
+ return {
265
+ search: state.search,
266
+ pills: state.pills,
267
+ ranges: state.ranges ?? {},
268
+ sortBy: state.sortBy,
269
+ // Absent, not undefined, when the table declares no scopes.
270
+ ...(scope !== undefined ? { scope } : {}),
271
+ page: server?.page ?? 1,
272
+ // Client mode has one page, and it is everything that matched.
273
+ pageSize: server?.pageSize ?? matchedCount,
274
+ };
275
+ }
276
+
277
+ /** The toolbar's active sort, read off the state's FIRST sort entry. */
278
+ function activeSortOf(
279
+ state: DataViewState,
280
+ resolvedSortFields: SortFieldDefinition[],
281
+ ): { activeSortField: string; activeSortOrder: "asc" | "desc" } {
282
+ const active = state.sortBy?.[0];
283
+ return {
284
+ activeSortField: active?.id ?? resolvedSortFields[0]?.value ?? "",
285
+ activeSortOrder: active?.dir === "desc" ? "desc" : "asc",
286
+ };
265
287
  }
266
288
 
267
289
  /**
@@ -286,43 +308,43 @@ export function useDataViewsState<T extends Record<string, unknown>>({
286
308
  rowActionsLeading,
287
309
  renderRowMenu,
288
310
  server,
311
+ scopes = [],
312
+ scopeFieldId,
289
313
  }: UseDataViewsStateArgs<T>): DataViewsController<T> {
290
- // Append the auto kebab column unless the page defines one (see helper).
291
- const columnsWithActions = useMemo(
292
- () =>
293
- appendActionsColumn(columns, { rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId }),
294
- [columns, rowActions, rowActionsLeading, renderRowMenu, testIdPrefix, getRowId],
295
- );
296
- const allColumnIds = useMemo(() => columnsWithActions.map((col) => col.id), [columnsWithActions]);
297
- // Seed once: a saved view (`appliedState`) or a pristine all-columns state, with
298
- // the URL-driven controls (`syncState`) merged over it so a deep-linked filter
299
- // is reflected on first render. Column visibility is never URL-carried.
300
- const [state, setState] = useState<DataViewState>(() => {
301
- const seed = appliedState ?? emptyViewState(allColumnIds);
302
- return syncState ? { ...seed, ...syncState } : seed;
314
+ useScopeConfigChecks(scopes, fields.map((field) => field.id), scopeFieldId, server !== undefined);
315
+ const { columnsWithActions, allColumnIds } = useColumnsWithActions(columns, {
316
+ rowActions,
317
+ rowActionsLeading,
318
+ renderRowMenu,
319
+ testIdPrefix,
320
+ getRowId,
303
321
  });
304
- const [selectedIds, setSelectedIdsState] = useState<Set<string | number>>(new Set());
322
+ // Seed once — see {@link seedState}.
323
+ const [state, setState] = useState<DataViewState>(() =>
324
+ seedState(appliedState, syncState, allColumnIds),
325
+ );
305
326
  const [filterOpen, setFilterOpen] = useState(false);
306
327
 
307
328
  useStateSyncEffects(appliedState, syncState, state, onStateChange, setState);
308
329
 
309
330
  const patch = (next: Partial<DataViewState>): void => setState((prev) => ({ ...prev, ...next }));
310
331
  const matched = useMatchedRows(server, rows, columns, fields, rangeFields, state);
332
+ // Resolved at READ time, every render: a stale deep link or a saved view naming
333
+ // a scope that has since been removed falls back to the first declared scope
334
+ // instead of putting an id the backend rejects on the wire.
335
+ const scope = resolveScope(scopes, state.scope);
311
336
  // Server mode: re-fetch on query change (page resets to 1) + drive pagination.
312
- const { changePage } = useServerQuery(server, state);
313
- const gridColumns = useMemo(
314
- () => buildGridColumns(columnsWithActions, state.visibleColumns, onRowClick),
315
- [columnsWithActions, onRowClick, state.visibleColumns],
337
+ const { changePage } = useServerQuery(server, state, scope);
338
+ const { gridColumns, hideableColumns } = useRenderedColumns(
339
+ columnsWithActions,
340
+ state.visibleColumns,
341
+ onRowClick,
342
+ state.order,
316
343
  );
317
- const hideableColumns = useMemo(() => computeHideableColumns(columnsWithActions), [columnsWithActions]);
318
344
  const toggleColumn = (id: string, visible: boolean): void =>
319
345
  setState((prev) => ({ ...prev, visibleColumns: nextVisibleColumns(prev.visibleColumns, id, visible) }));
320
- const selectedRows = useMemo(
321
- () => matched.filter((row) => selectedIds.has(getRowId(row))),
322
- [matched, selectedIds, getRowId],
323
- );
346
+ const selection = useSelection(matched, getRowId);
324
347
  const resolvedSortFields = sortFields ?? defaultSortFields(columns);
325
- const activeSort = state.sortBy?.[0];
326
348
 
327
349
  return {
328
350
  state,
@@ -331,19 +353,21 @@ export function useDataViewsState<T extends Record<string, unknown>>({
331
353
  matched,
332
354
  gridColumns,
333
355
  hideableColumns,
356
+ columnOrder: orderedColumnIds(allColumnIds, state.order),
334
357
  toggleColumn,
335
- selectedIds,
336
- setSelectedIds: setSelectedIdsState,
337
- selectedRows,
338
- clearSelection: () => setSelectedIdsState(new Set()),
339
- selectAll: () => setSelectedIdsState(new Set(matched.map((row) => getRowId(row)))),
358
+ ...selection,
340
359
  filterOpen,
341
360
  setFilterOpen,
342
361
  resolvedSortFields,
343
- activeSortField: activeSort?.id ?? resolvedSortFields[0]?.value ?? "",
344
- activeSortOrder: activeSort?.dir === "desc" ? "desc" : "asc",
362
+ ...activeSortOf(state, resolvedSortFields),
345
363
  activeFilterCount: computeActiveFilterCount(state, fields, rangeFields),
346
364
  ...serverDerived(server, matched.length),
347
365
  changePage,
366
+ scope,
367
+ setScope: makeSetScope(patch, selection.clearSelection),
368
+ currentQuery: liveQuery(state, scope, server, matched.length),
369
+ // Verbatim, or absent. See DataViewServer.scopeCounts for why there is no
370
+ // client-side fallback.
371
+ scopeCounts: server?.scopeCounts,
348
372
  };
349
373
  }
@@ -0,0 +1,171 @@
1
+ "use client";
2
+
3
+ import { useRef, useState } from "react";
4
+
5
+ import { useViewMutations, type ViewMutations } from "./data-views-view-mutations";
6
+ import {
7
+ coerceViewState,
8
+ emptyViewState,
9
+ type DataViewPersistence,
10
+ type DataViewRouter,
11
+ type DataViewState,
12
+ type SavedViewSummary,
13
+ } from "./data-views-types";
14
+ import { type SaveViewPayload } from "./SaveViewModal";
15
+
16
+ export interface SavedViewsController {
17
+ applied: DataViewState;
18
+ /** Name of the currently-applied saved view, if any (drives the trigger label). */
19
+ activeViewName?: string;
20
+ /** Reset to the built-in no-filter "Main vision" default (clears the URL param). */
21
+ selectMain: () => void;
22
+ currentRef: React.MutableRefObject<DataViewState>;
23
+ saveOpen: boolean;
24
+ editing: SavedViewSummary | null;
25
+ manageOpen: boolean;
26
+ setSaveOpen: (open: boolean) => void;
27
+ setEditing: (view: SavedViewSummary | null) => void;
28
+ setManageOpen: (open: boolean) => void;
29
+ applyView: (view: SavedViewSummary) => void;
30
+ openCreate: () => void;
31
+ openEdit: (view: SavedViewSummary) => void;
32
+ /** Close the save modal and clear the editing target. */
33
+ closeSave: () => void;
34
+ /** Close the manage dialog and open the edit modal for `view`. */
35
+ editFromManage: (view: SavedViewSummary) => void;
36
+ patchView: ViewMutations["patchView"];
37
+ handleDelete: ViewMutations["handleDelete"];
38
+ handleSave: (payload: SaveViewPayload) => Promise<void>;
39
+ /** Last pin/share/default/delete failure — shown as an Alert, never silent. */
40
+ mutationError: string | null;
41
+ clearMutationError: () => void;
42
+ }
43
+
44
+ interface AppliedView {
45
+ applied: DataViewState;
46
+ currentRef: React.MutableRefObject<DataViewState>;
47
+ activeViewName?: string;
48
+ applyView: (view: SavedViewSummary) => void;
49
+ selectMain: () => void;
50
+ }
51
+
52
+ /**
53
+ * The applied grid state + which saved view it came from, kept in sync with the
54
+ * URL (`?view=<id>`) so a refresh restores it. "Main vision" resets + clears it.
55
+ */
56
+ function useAppliedView(
57
+ router: DataViewRouter,
58
+ columnIds: string[],
59
+ views: SavedViewSummary[],
60
+ initialView: SavedViewSummary | undefined,
61
+ initialState: DataViewState | undefined,
62
+ ): AppliedView {
63
+ // A saved view wins; otherwise seed from the host-supplied initial state (e.g.
64
+ // filters/sort read off the URL, FUT-180) so a bookmarked filtered URL is
65
+ // reflected in the grid — column visibility isn't URL-carried, so show all.
66
+ const seed = initialView
67
+ ? coerceViewState(initialView.state, columnIds)
68
+ : initialState
69
+ ? { ...initialState, visibleColumns: columnIds }
70
+ : emptyViewState(columnIds);
71
+ const [applied, setApplied] = useState<DataViewState>(seed);
72
+ const [appliedViewId, setAppliedViewId] = useState<string | null>(initialView?.id ?? null);
73
+ const currentRef = useRef<DataViewState>(seed);
74
+
75
+ const applyView = (view: SavedViewSummary): void => {
76
+ setApplied(coerceViewState(view.state, columnIds));
77
+ setAppliedViewId(view.id);
78
+ router.syncViewParam(view.id);
79
+ };
80
+ const selectMain = (): void => {
81
+ setApplied(emptyViewState(columnIds));
82
+ setAppliedViewId(null);
83
+ router.syncViewParam(null);
84
+ };
85
+ const activeViewName = views.find((view) => view.id === appliedViewId)?.name;
86
+ return { applied, currentRef, activeViewName, applyView, selectMain };
87
+ }
88
+
89
+ /**
90
+ * Owns the saved-views state + persistence for {@link DataViewsTable}: the applied
91
+ * grid state, the live-state ref, the modal/dialog flags, and the create/edit/
92
+ * delete/patch handlers. Extracted so the component render stays within budget.
93
+ */
94
+ export function useSavedViewsController(
95
+ persistence: DataViewPersistence,
96
+ router: DataViewRouter,
97
+ columnIds: string[],
98
+ views: SavedViewSummary[],
99
+ initialView: SavedViewSummary | undefined,
100
+ initialState: DataViewState | undefined,
101
+ ): SavedViewsController {
102
+ const { applied, currentRef, activeViewName, applyView, selectMain } =
103
+ useAppliedView(router, columnIds, views, initialView, initialState);
104
+ const [saveOpen, setSaveOpen] = useState(false);
105
+ const [editing, setEditing] = useState<SavedViewSummary | null>(null);
106
+ const [manageOpen, setManageOpen] = useState(false);
107
+
108
+ const openCreate = (): void => {
109
+ setEditing(null);
110
+ setSaveOpen(true);
111
+ };
112
+ const openEdit = (view: SavedViewSummary): void => {
113
+ setEditing(view);
114
+ setSaveOpen(true);
115
+ };
116
+
117
+ const [mutationError, setMutationError] = useState<string | null>(null);
118
+ const refreshAfter = (ok: boolean, error?: string): void => {
119
+ if (ok) {
120
+ setMutationError(null);
121
+ router.refresh();
122
+ } else {
123
+ // A rejected pin/share/default/delete must be visible (FUT-100) — the
124
+ // menus close on click, so an inline Alert is the only surviving signal.
125
+ setMutationError(error ?? "Não foi possível salvar a alteração da visão.");
126
+ }
127
+ };
128
+
129
+ const { handleSave: saveView, patchView, handleDelete } = useViewMutations(
130
+ persistence,
131
+ editing,
132
+ currentRef,
133
+ refreshAfter,
134
+ );
135
+
136
+ const handleSave = async (payload: SaveViewPayload): Promise<void> => {
137
+ await saveView(payload);
138
+ setSaveOpen(false);
139
+ setEditing(null);
140
+ };
141
+
142
+ return {
143
+ applied,
144
+ activeViewName,
145
+ selectMain,
146
+ currentRef,
147
+ saveOpen,
148
+ editing,
149
+ manageOpen,
150
+ setSaveOpen,
151
+ setEditing,
152
+ setManageOpen,
153
+ applyView,
154
+ openCreate,
155
+ openEdit,
156
+ closeSave: () => {
157
+ setSaveOpen(false);
158
+ setEditing(null);
159
+ },
160
+ editFromManage: (view: SavedViewSummary) => {
161
+ setManageOpen(false);
162
+ openEdit(view);
163
+ },
164
+ patchView,
165
+ handleDelete,
166
+ handleSave,
167
+ mutationError,
168
+ clearMutationError: () => setMutationError(null),
169
+ };
170
+ }
171
+