@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
|
@@ -7,12 +7,14 @@ import { type SortFieldDefinition } from "../../layout/ContentToolbar";
|
|
|
7
7
|
|
|
8
8
|
import { filterRows } from "./data-views-filter";
|
|
9
9
|
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
} from "./data-views-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
*/
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
291
|
-
const columnsWithActions =
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
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 =
|
|
314
|
-
|
|
315
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
|