@12-apps/ui 1.5.2 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +4 -4
  2. package/src/components/data-display/DataViews/DataViewsBoard.tsx +327 -0
  3. package/src/components/data-display/DataViews/DataViewsGrid.tsx +80 -77
  4. package/src/components/data-display/DataViews/DataViewsTableBase.tsx +178 -230
  5. package/src/components/data-display/DataViews/DeleteViewDialog.tsx +83 -0
  6. package/src/components/data-display/DataViews/SaveViewModal.tsx +140 -52
  7. package/src/components/data-display/DataViews/data-views-columns-tab.tsx +240 -0
  8. package/src/components/data-display/DataViews/data-views-columns.ts +129 -0
  9. package/src/components/data-display/DataViews/data-views-dirty.ts +62 -0
  10. package/src/components/data-display/DataViews/data-views-display-panel.tsx +328 -0
  11. package/src/components/data-display/DataViews/data-views-display-tabs.tsx +328 -0
  12. package/src/components/data-display/DataViews/data-views-empty.tsx +91 -0
  13. package/src/components/data-display/DataViews/data-views-export.tsx +260 -0
  14. package/src/components/data-display/DataViews/data-views-glyphs.tsx +103 -0
  15. package/src/components/data-display/DataViews/data-views-grid-bodies.tsx +340 -0
  16. package/src/components/data-display/DataViews/data-views-grid-parts.tsx +220 -242
  17. package/src/components/data-display/DataViews/data-views-inline-bar.tsx +187 -81
  18. package/src/components/data-display/DataViews/data-views-layout-context.tsx +215 -79
  19. package/src/components/data-display/DataViews/data-views-more-filters.tsx +300 -0
  20. package/src/components/data-display/DataViews/data-views-overflow.ts +302 -0
  21. package/src/components/data-display/DataViews/data-views-preview.ts +11 -3
  22. package/src/components/data-display/DataViews/data-views-range-pill.tsx +162 -92
  23. package/src/components/data-display/DataViews/data-views-range-values.ts +69 -0
  24. package/src/components/data-display/DataViews/data-views-scopes.tsx +356 -0
  25. package/src/components/data-display/DataViews/data-views-search.tsx +156 -0
  26. package/src/components/data-display/DataViews/data-views-select-all-strip.tsx +105 -0
  27. package/src/components/data-display/DataViews/data-views-selection.ts +50 -0
  28. package/src/components/data-display/DataViews/data-views-server-query.ts +75 -0
  29. package/src/components/data-display/DataViews/data-views-shell-toolbar.tsx +87 -0
  30. package/src/components/data-display/DataViews/data-views-table-parts.tsx +5 -0
  31. package/src/components/data-display/DataViews/data-views-toolbar.tsx +63 -48
  32. package/src/components/data-display/DataViews/data-views-types.ts +88 -9
  33. package/src/components/data-display/DataViews/data-views-view-nav.tsx +378 -0
  34. package/src/components/data-display/DataViews/index.ts +14 -1
  35. package/src/components/data-display/DataViews/use-data-views-state.ts +158 -134
  36. package/src/components/data-display/DataViews/use-saved-views-controller.ts +171 -0
  37. package/src/components/layout/ContentToolbar/ContentToolbar.tsx +86 -30
  38. package/src/components/layout/ContentToolbar/ContentToolbar.types.ts +25 -0
  39. package/src/components/layout/ContentToolbar/MultiSelectDropdown.tsx +6 -1
  40. package/src/components/utility/InstallPrompt/InstallPrompt.earlyCapture.ts +117 -0
  41. package/src/components/utility/InstallPrompt/InstallPrompt.md +52 -4
  42. package/src/components/utility/InstallPrompt/InstallPrompt.types.ts +19 -0
  43. package/src/components/utility/InstallPrompt/index.ts +8 -0
  44. package/src/components/utility/InstallPrompt/usePwaInstall.ts +39 -11
@@ -0,0 +1,75 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ import type { DataViewServer, DataViewState } from "./data-views-types";
6
+
7
+ /**
8
+ * Server-mode wiring extracted from {@link useDataViewsState} (keeps it within
9
+ * the size/complexity budget): re-fetch on every effective-query change (page
10
+ * resets to 1) and expose `changePage` for the pagination control. Read the
11
+ * latest `state`/`server` at fire time so an inline `server` object per render
12
+ * doesn't loop the effect.
13
+ */
14
+ export function useServerQuery(
15
+ server: DataViewServer | undefined,
16
+ state: DataViewState,
17
+ scope: string | undefined,
18
+ ): {
19
+ changePage: (page: number) => void;
20
+ } {
21
+ const emit = (page: number): void => {
22
+ if (!server) return;
23
+ server.onQueryChange({
24
+ search: state.search,
25
+ pills: state.pills,
26
+ ranges: state.ranges ?? {},
27
+ sortBy: state.sortBy,
28
+ // Spread rather than `scope: scope`: a table declaring no scopes must emit
29
+ // a query with NO `scope` key at all, not one present-and-undefined that
30
+ // every host would then have to know to ignore.
31
+ ...(scope !== undefined ? { scope } : {}),
32
+ page,
33
+ pageSize: server.pageSize,
34
+ });
35
+ };
36
+ const queryKey = server
37
+ ? JSON.stringify({
38
+ q: state.search,
39
+ pills: state.pills,
40
+ ranges: state.ranges ?? {},
41
+ sort: state.sortBy,
42
+ // The RESOLVED scope, so a deep link naming a removed scope re-fetches
43
+ // once for the fallback rather than looping on the stored id.
44
+ scope,
45
+ })
46
+ : "";
47
+ const firstQuery = useRef(true);
48
+ useEffect(() => {
49
+ if (!server) return;
50
+ if (firstQuery.current) {
51
+ firstQuery.current = false;
52
+ return;
53
+ }
54
+ emit(1);
55
+ // Emit only when the query key changes; `emit` reads fresh state at fire time.
56
+ }, [queryKey]);
57
+ return { changePage: (page: number) => emit(page) };
58
+ }
59
+
60
+ /** The controller's server-mode derived fields (or client-mode defaults). */
61
+ export function serverDerived(
62
+ server: DataViewServer | undefined,
63
+ fallbackCount: number,
64
+ ): { serverMode: boolean; serverPage: number; serverPageCount: number; serverTotalCount: number } {
65
+ if (!server) {
66
+ return { serverMode: false, serverPage: 1, serverPageCount: 1, serverTotalCount: fallbackCount };
67
+ }
68
+ return {
69
+ serverMode: true,
70
+ serverPage: server.page,
71
+ serverPageCount: Math.max(1, Math.ceil(server.totalCount / server.pageSize)),
72
+ serverTotalCount: server.totalCount,
73
+ };
74
+ }
75
+
@@ -0,0 +1,87 @@
1
+ "use client";
2
+
3
+ /**
4
+ * The toolbar band, fed from the controller — ~25 props of pure forwarding and
5
+ * nothing else, which is why it lives in its own file rather than in the shell.
6
+ */
7
+ import { type ColumnVisibilityOption } from "../../layout/ContentToolbar";
8
+
9
+ import type { DisplayPanelView } from "./data-views-display-panel";
10
+ import type { DataViewExport } from "./data-views-export";
11
+ import { GridToolbar } from "./data-views-toolbar";
12
+ import type { RowAction } from "./data-views-types";
13
+ import type { DataViewsController } from "./use-data-views-state";
14
+
15
+ /**
16
+ * The toolbar band, fed from the controller. Split out of `GridShell` for that
17
+ * function's size budget — it is ~25 props of pure forwarding and nothing else.
18
+ */
19
+ export function ShellToolbar<T extends Record<string, unknown>>({
20
+ c,
21
+ rows,
22
+ testIdPrefix,
23
+ rowActions,
24
+ bulkActions,
25
+ toolbarRightSlot,
26
+ showInline,
27
+ compactControls,
28
+ sortKinds,
29
+ displayView,
30
+ exportConfig,
31
+ filterControls,
32
+ barRef,
33
+ }: {
34
+ c: DataViewsController<T>;
35
+ rows: T[];
36
+ testIdPrefix: string;
37
+ sortKinds?: Record<string, string>;
38
+ displayView?: DisplayPanelView;
39
+ exportConfig?: DataViewExport;
40
+ rowActions?: RowAction<T>[];
41
+ bulkActions?: (selectedRows: T[], clearSelection: () => void) => React.ReactNode;
42
+ toolbarRightSlot?: React.ReactNode;
43
+ /** Search + filters, rendered on the toolbar line (see `GridToolbar`). */
44
+ filterControls?: React.ReactNode;
45
+ barRef?: React.Ref<HTMLDivElement>;
46
+ showInline: boolean;
47
+ /** Step 2 of the ladder: Exibir/Exportar as icons only. */
48
+ compactControls?: boolean;
49
+ }): React.JSX.Element {
50
+ const columnOptions: ColumnVisibilityOption[] = c.hideableColumns.map((col) => ({
51
+ id: col.id,
52
+ label: col.label,
53
+ visible: c.state.visibleColumns.includes(col.id),
54
+ }));
55
+ return (
56
+ <GridToolbar
57
+ c={c}
58
+ sortKinds={sortKinds}
59
+ displayView={displayView}
60
+ exportConfig={exportConfig}
61
+ filterControls={filterControls}
62
+ barRef={barRef}
63
+ compactControls={compactControls}
64
+ testIdPrefix={testIdPrefix}
65
+ selectedRows={c.selectedRows}
66
+ selectAll={c.selectAll}
67
+ clearSelection={c.clearSelection}
68
+ rowActions={rowActions}
69
+ bulkActions={bulkActions}
70
+ sortFields={c.resolvedSortFields}
71
+ activeSortField={c.activeSortField}
72
+ activeSortOrder={c.activeSortOrder}
73
+ onChangeSort={(field, order) => c.patch({ sortBy: [{ id: field, dir: order }] })}
74
+ matchedCount={c.matched.length}
75
+ totalCount={c.serverMode ? c.serverTotalCount : rows.length}
76
+ toolbarRightSlot={toolbarRightSlot}
77
+ columnOptions={columnOptions}
78
+ onToggleColumn={c.toggleColumn}
79
+ filterOpen={c.filterOpen}
80
+ setFilterOpen={c.setFilterOpen}
81
+ activeFilterCount={c.activeFilterCount}
82
+ showFilterTrigger={!showInline}
83
+ />
84
+ );
85
+ }
86
+
87
+ /** Wires the controller into the shared toolbar + filter panel + grid/cards body. */
@@ -127,7 +127,12 @@ export function ViewDialogs<T extends Record<string, unknown>>({
127
127
  }: ViewDialogsProps<T>): React.JSX.Element {
128
128
  return (
129
129
  <>
130
+ {/* Keyed by the view being edited: the form seeds its fields with
131
+ `useState`, which only runs on MOUNT, so a modal that stayed mounted
132
+ from a previous open reused those values — "Renomear e editar" opened
133
+ with an empty Nome once "Salvar visão" had been opened first. */}
130
134
  <SaveViewModal<T>
135
+ key={editing ? `edit-${editing.id}` : "create"}
131
136
  open={saveOpen}
132
137
  onClose={onCloseSave}
133
138
  currentState={currentState}
@@ -1,23 +1,21 @@
1
1
  "use client";
2
2
 
3
- import CollapseIcon from '@mui/icons-material/ExpandLess';
4
- import ExpandIcon from '@mui/icons-material/ExpandMore';
5
- import { IconButton, Tooltip } from "@mui/material";
3
+ import { Divider } from "@mui/material";
6
4
 
7
5
  import {
8
- ColumnsMenu,
9
6
  ContentToolbar,
10
7
  FilterTrigger,
11
- SortByDropdown,
12
8
  type ColumnVisibilityOption,
13
9
  type SortFieldDefinition,
14
10
  } from "../../layout/ContentToolbar";
15
11
  import { Box } from "../../../mui/Box";
16
12
  import { Text } from "../../typography/Text";
17
13
 
18
- import { DataViewsLayoutToggle, DataViewsZoomSlider } from "./data-views-layout-context";
14
+ import { DataViewsDisplayPanel, type DisplayPanelView } from "./data-views-display-panel";
15
+ import { DataViewsExportMenu, type DataViewExport } from "./data-views-export";
19
16
  import { renderBulkActions } from "./data-views-grid-helpers";
20
17
  import type { RowAction } from "./data-views-types";
18
+ import type { DataViewsController } from "./use-data-views-state";
21
19
 
22
20
  export interface GridToolbarProps<T extends Record<string, unknown>> {
23
21
  testIdPrefix: string;
@@ -40,49 +38,74 @@ export interface GridToolbarProps<T extends Record<string, unknown>> {
40
38
  activeFilterCount: number;
41
39
  /** Hide the "Filtros" slide-in trigger (compact layout shows filters inline). */
42
40
  showFilterTrigger?: boolean;
43
- /** Show the ∧/∨ toggle that collapses/expands the inline filter row. */
44
- showFiltersToggle?: boolean;
45
- /** Whether the inline filter row is currently hidden. */
46
- filtersHidden?: boolean;
47
- /** Toggle the inline filter row's visibility. */
48
- onToggleFilters?: () => void;
41
+ /**
42
+ * Search + filter controls, rendered ON the toolbar line rather than on a row
43
+ * beneath it. Absent ⇒ the toolbar keeps its plain two-cluster shape.
44
+ */
45
+ filterControls?: React.ReactNode;
46
+ /** Measures the toolbar ROW for the filter overflow — see `useFilterOverflow`. */
47
+ barRef?: React.Ref<HTMLDivElement>;
48
+ /** The controller, so "Exibir" can drive sort, columns, order and format. */
49
+ c: DataViewsController<T>;
50
+ /** Per-sort-field value kind, so directions read in the column's own terms. */
51
+ sortKinds?: Record<string, string>;
52
+ /** The saved-view chrome bracketing the Exibir panel, when the host has views. */
53
+ displayView?: DisplayPanelView;
54
+ /** Injected export. Absent ⇒ no Exportar control. */
55
+ exportConfig?: DataViewExport;
56
+ /**
57
+ * Step 2 of the degradation ladder: Exibir/Exportar as icons only.
58
+ * MEASURED upstream (see `useFilterOverflow`) rather than switched at a
59
+ * breakpoint, so a two-filter page keeps its labels where a five-filter one
60
+ * has already given them up.
61
+ */
62
+ compactControls?: boolean;
49
63
  }
50
64
 
51
65
  /** The right-aligned toolbar controls: Sort By, the counter, zoom/layout/columns, filters. */
52
66
  function ToolbarRightControls<T extends Record<string, unknown>>(props: GridToolbarProps<T>): React.JSX.Element {
53
- const { testIdPrefix, sortFields, matchedCount, totalCount, filterOpen, setFilterOpen } = props;
67
+ const { testIdPrefix, matchedCount, totalCount, filterOpen, setFilterOpen } = props;
54
68
  return (
55
69
  <>
56
- {sortFields.length > 0 && (
57
- <SortByDropdown
58
- fields={sortFields}
59
- activeField={props.activeSortField}
60
- activeOrder={props.activeSortOrder}
61
- onFieldChange={(field) => props.onChangeSort(field, "asc")}
62
- onOrderChange={(order) => props.onChangeSort(props.activeSortField, order === "desc" ? "desc" : "asc")}
63
- data-testid={`${testIdPrefix}-sort-trigger`}
64
- />
65
- )}
66
70
  <Text variant="caption" as="span">
67
- {/* The "N de N" counter is hidden on mobile to keep the toolbar one line. */}
71
+ {/* Always rendered, never breakpointed: `RESERVED.counter` already
72
+ prices it into the collapse ladder, so hiding it at `md` was the
73
+ ladder and a breakpoint disagreeing about the same pixels — which
74
+ is what broke the row at exactly 900px. */}
68
75
  <Box
69
76
  component="span"
70
77
  data-testid={`${testIdPrefix}-counter`}
71
- sx={{ whiteSpace: "nowrap", display: { xs: "none", md: "inline" } }}
78
+ sx={{ whiteSpace: "nowrap" }}
72
79
  >
73
- Exibindo {matchedCount} de {totalCount}
80
+ {matchedCount} de {totalCount}
74
81
  </Box>
75
82
  </Text>
83
+ {/* Divides the READING of the list from the controls that change it —
84
+ the counter states what is on screen, everything right of here acts
85
+ on it. */}
86
+ <Divider
87
+ orientation="vertical"
88
+ flexItem
89
+ sx={{ height: 20, alignSelf: "center", mx: 0.5 }}
90
+ />
76
91
  {props.toolbarRightSlot}
77
- <DataViewsZoomSlider testIdPrefix={testIdPrefix} />
78
- <DataViewsLayoutToggle testIdPrefix={testIdPrefix} />
79
- {props.columnOptions.length > 0 && (
80
- <ColumnsMenu
81
- columns={props.columnOptions}
82
- onToggle={props.onToggleColumn}
83
- title="Colunas"
84
- ariaLabel="Exibir colunas"
85
- data-testid={`${testIdPrefix}-columns-toggle`}
92
+ {/* Sort + columns + format, in ONE control — see DataViewsDisplayPanel. */}
93
+ <DataViewsDisplayPanel
94
+ c={props.c}
95
+ testIdPrefix={testIdPrefix}
96
+ sortKinds={props.sortKinds}
97
+ view={props.displayView}
98
+ compact={props.compactControls}
99
+ />
100
+ {props.exportConfig && (
101
+ <DataViewsExportMenu
102
+ config={props.exportConfig}
103
+ query={props.c.currentQuery}
104
+ totalCount={props.totalCount}
105
+ selectedIds={[...props.c.selectedIds]}
106
+ columns={props.columnOptions.filter((col) => col.visible).map((col) => ({ id: col.id, label: col.label }))}
107
+ testIdPrefix={testIdPrefix}
108
+ compact={props.compactControls}
86
109
  />
87
110
  )}
88
111
  {props.showFilterTrigger !== false && (
@@ -93,19 +116,6 @@ function ToolbarRightControls<T extends Record<string, unknown>>(props: GridTool
93
116
  data-testid={`${testIdPrefix}-filters-toggle`}
94
117
  />
95
118
  )}
96
- {props.showFiltersToggle && (
97
- <Tooltip title={props.filtersHidden ? "Mostrar filtros" : "Ocultar filtros"}>
98
- <IconButton
99
- size="small"
100
- onClick={props.onToggleFilters}
101
- aria-label={props.filtersHidden ? "Mostrar filtros" : "Ocultar filtros"}
102
- aria-expanded={!props.filtersHidden}
103
- data-testid={`${testIdPrefix}-filters-collapse`}
104
- >
105
- {props.filtersHidden ? <ExpandIcon fontSize="small" /> : <CollapseIcon fontSize="small" />}
106
- </IconButton>
107
- </Tooltip>
108
- )}
109
119
  </>
110
120
  );
111
121
  }
@@ -119,6 +129,7 @@ export function GridToolbar<T extends Record<string, unknown>>(props: GridToolba
119
129
  const { testIdPrefix, selectedRows } = props;
120
130
  return (
121
131
  <Box
132
+ ref={props.barRef}
122
133
  sx={{
123
134
  borderTop: 1,
124
135
  borderBottom: 1,
@@ -142,6 +153,10 @@ export function GridToolbar<T extends Record<string, unknown>>(props: GridToolba
142
153
  clearSelection: props.clearSelection,
143
154
  testIdPrefix,
144
155
  })}
156
+ leadingControls={props.filterControls}
157
+ // Selection takes the whole line: no Select All while browsing, and no
158
+ // search/filters/Exibir/Exportar once a row is ticked.
159
+ exclusiveSelection
145
160
  rightControls={<ToolbarRightControls {...props} />}
146
161
  />
147
162
  </Box>
@@ -164,6 +164,14 @@ export interface DataViewColumn<T extends Record<string, unknown>> extends GridC
164
164
  hideable?: boolean;
165
165
  }
166
166
 
167
+ /**
168
+ * The DataViews layout (view type): the dense "Tabela" grid, the "Grade" of
169
+ * cards, the "Lista" of full-width rows, or the "Quadro" board of state columns.
170
+ * Lives with the state types rather than with the toggle because a saved view
171
+ * CAPTURES it — see {@link DataViewState.layout}.
172
+ */
173
+ export type DataViewsLayout = "cards" | "list" | "board" | "table";
174
+
167
175
  /** The full, serializable state a view captures. */
168
176
  export interface DataViewState {
169
177
  /** Free text matched across every `searchable` column. */
@@ -175,6 +183,36 @@ export interface DataViewState {
175
183
  sortBy: GridSort[];
176
184
  /** Column ids kept visible; a hideable column not listed here is hidden. */
177
185
  visibleColumns: string[];
186
+ /**
187
+ * Column ids in READING ORDER. Absent ⇒ the order the table declared them in.
188
+ *
189
+ * Order is view state for the same reason visibility is: reordering columns is
190
+ * an operator deciding what to read FIRST, and a saved view that restored the
191
+ * columns but not their order would restore half a decision. Ids the table no
192
+ * longer declares are ignored at render, and ids missing from a stale list
193
+ * fall in after it — so a column added since the view was saved appears rather
194
+ * than disappearing.
195
+ */
196
+ order?: string[];
197
+ /**
198
+ * The active SCOPE id — the page-level partition the scope tabs select (see
199
+ * `ScopeConfig`). Optional, so a table that declares no scopes is untouched
200
+ * and no view persisted before scopes existed breaks.
201
+ *
202
+ * Stored raw and RESOLVED at read time: the declared scopes change underneath
203
+ * a saved view, and a view naming a scope that has since been removed must
204
+ * fall back at render rather than break.
205
+ */
206
+ scope?: string;
207
+ /**
208
+ * The layout the view was saved in. Optional — absent means "whatever the user
209
+ * is currently using", which is the pre-existing behaviour.
210
+ *
211
+ * A LAYOUT FALLBACK IS NOT A LAYOUT CHANGE: a table rendered as stacked rows
212
+ * on a phone still has `"table"` stored here, so the view round-trips the
213
+ * stored layout regardless of the device that opened it.
214
+ */
215
+ layout?: DataViewsLayout;
178
216
  }
179
217
 
180
218
  /** A pristine view state showing every given column and no filters/sort. */
@@ -190,7 +228,10 @@ export function emptyViewState(visibleColumns: string[]): DataViewState {
190
228
  * over the current state so a user's show/hide column choices are preserved —
191
229
  * unlike `appliedState`, which fully replaces the state to apply a saved view.
192
230
  */
193
- export type DataViewSyncState = Pick<DataViewState, "search" | "pills" | "ranges" | "sortBy">;
231
+ export type DataViewSyncState = Pick<
232
+ DataViewState,
233
+ "search" | "pills" | "ranges" | "sortBy" | "scope"
234
+ >;
194
235
 
195
236
  /**
196
237
  * The query a SERVER-mode table emits whenever its filter/sort/page changes
@@ -204,6 +245,16 @@ export interface DataViewQuery {
204
245
  pills: Record<string, string[]>;
205
246
  ranges: Record<string, RangeValue>;
206
247
  sortBy: GridSort[];
248
+ /**
249
+ * The RESOLVED active scope (see `resolveScope`), when the table declares
250
+ * scopes. The KEY IS ABSENT for a table that declares none — not present-and-
251
+ * undefined — so a host that never opted in sees a byte-identical query.
252
+ *
253
+ * It carries the resolved id, never the stored one: a deep link or a saved
254
+ * view naming a scope the table no longer declares must not put that id on the
255
+ * wire, where the backend would reject it and replace the page with an error.
256
+ */
257
+ scope?: string;
207
258
  page: number;
208
259
  pageSize: number;
209
260
  }
@@ -225,6 +276,20 @@ export interface DataViewServer {
225
276
  pageSize: number;
226
277
  /** Fired when the effective query changes; the host re-fetches the page. */
227
278
  onQueryChange: (query: DataViewQuery) => void;
279
+ /**
280
+ * Per-scope totals for the scope tabs: `scopeId → count`. THE ONLY PIECE OF
281
+ * THIS CONTRACT THAT CANNOT BE DONE CLIENT-SIDE — a count computed from the
282
+ * loaded page is wrong the moment there is a second page, which is the
283
+ * concrete bug scopes exist to fix.
284
+ *
285
+ * Compute it over search + pills + ranges while IGNORING the active scope.
286
+ * Computed WITH the scope applied, every inactive tab reads zero, which is
287
+ * worse than no counts at all.
288
+ *
289
+ * Omit it and the tabs render with no numbers. Nothing is ever invented from
290
+ * the loaded rows to fill the gap.
291
+ */
292
+ scopeCounts?: Record<string, number>;
228
293
  }
229
294
 
230
295
  /**
@@ -236,19 +301,33 @@ export function coerceViewState(raw: unknown, allColumnIds: string[]): DataViewS
236
301
  const value = (raw ?? {}) as Partial<DataViewState>;
237
302
  return {
238
303
  search: typeof value.search === "string" ? value.search : "",
239
- pills:
240
- value.pills && typeof value.pills === "object"
241
- ? (value.pills as Record<string, string[]>)
242
- : {},
243
- ranges:
244
- value.ranges && typeof value.ranges === "object"
245
- ? (value.ranges as Record<string, RangeValue>)
246
- : {},
304
+ pills: asRecord<string[]>(value.pills),
305
+ ranges: asRecord<RangeValue>(value.ranges),
247
306
  sortBy: Array.isArray(value.sortBy) ? value.sortBy : [],
248
307
  visibleColumns: Array.isArray(value.visibleColumns) ? value.visibleColumns : allColumnIds,
308
+ order: Array.isArray(value.order) ? value.order : undefined,
309
+ // Carried through UNVALIDATED against the declared scopes on purpose: a view
310
+ // written before scopes existed has none (⇒ undefined ⇒ the first declared
311
+ // scope at render), and one naming a since-removed scope keeps its stored id
312
+ // here and falls back at READ time. Resolving here would rewrite the view.
313
+ scope: typeof value.scope === "string" ? value.scope : undefined,
314
+ layout: isLayout(value.layout) ? value.layout : undefined,
249
315
  };
250
316
  }
251
317
 
318
+ /** Every layout this build knows how to render — the guard's single source. */
319
+ export const DATA_VIEWS_LAYOUTS: readonly DataViewsLayout[] = ["cards", "list", "board", "table"];
320
+
321
+ /** Is this a layout this build knows how to render? Guards persisted JSON. */
322
+ function isLayout(value: unknown): value is DataViewsLayout {
323
+ return DATA_VIEWS_LAYOUTS.includes(value as DataViewsLayout);
324
+ }
325
+
326
+ /** A persisted map, or an empty one — persisted JSON can hold anything. */
327
+ function asRecord<V>(value: unknown): Record<string, V> {
328
+ return value !== null && typeof value === "object" ? (value as Record<string, V>) : {};
329
+ }
330
+
252
331
  /** A saved view as consumed by the DataViews UI (FUT-89), with ownership flag. */
253
332
  export interface SavedViewSummary {
254
333
  id: string;