@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
|
@@ -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
|
|
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 {
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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,
|
|
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
|
-
{/*
|
|
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"
|
|
78
|
+
sx={{ whiteSpace: "nowrap" }}
|
|
72
79
|
>
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
<
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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<
|
|
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
|
-
|
|
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;
|