@open-mercato/ui 0.6.8-develop.6914.1.6c7a5dcb6b → 0.6.8-develop.6916.1.f04fff3436
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/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +1 -0
- package/dist/backend/DataTable.js +167 -8
- package/dist/backend/DataTable.js.map +3 -3
- package/dist/backend/perspectiveDirty.js +55 -0
- package/dist/backend/perspectiveDirty.js.map +7 -0
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +309 -6
- package/src/backend/__tests__/DataTable.saveViewApi.test.tsx +474 -0
- package/src/backend/__tests__/perspectiveDirty.test.ts +86 -0
- package/src/backend/perspectiveDirty.ts +136 -0
- package/src/backend/utils/__tests__/nav.itemOrder.test.ts +85 -0
package/.turbo/turbo-build.log
CHANGED
package/AGENTS.md
CHANGED
|
@@ -291,6 +291,7 @@ const leadTagMap: TagMap<'customer' | 'hot' | 'inactive' | 'renewal'> = {
|
|
|
291
291
|
- Use `RowActions` for per-row actions; navigate via `onRowClick` or action links.
|
|
292
292
|
- Keep table state (paging, sorting, filters, search) in component state and reload on scope changes.
|
|
293
293
|
- Keep `extensionTableId` stable and deterministic.
|
|
294
|
+
- Prefer `showSaveViewButton` for "Save view" affordances; build your own on `viewApiRef`/`onColumnsDirtyChange` only for a host with a custom `toolbar`. Never patch `DataTable`. See `apps/docs/docs/framework/admin-ui/perspectives.mdx`.
|
|
294
295
|
- Render injected row actions and bulk actions through `RowActions`/bulk handlers so they follow the same guard and i18n behavior as built-ins.
|
|
295
296
|
- For mutating bulk actions, show operation progress in `ProgressTopBar`: return `{ ok, progressJobId }` from server/queued actions, or use shared bulk helpers that emit client-local progress events for browser-bound loops. MUST NOT add custom per-page progress bars for DataTable bulk work.
|
|
296
297
|
- Prefer server-side `ProgressJob` + queue workers for bulk work that may exceed one second, touch many records, call external services, or should continue after navigation. Use client-local progress only for short in-page loops that intentionally preserve response-side metadata such as undo headers.
|
|
@@ -5,7 +5,7 @@ import { useRouter } from "next/navigation";
|
|
|
5
5
|
import { flexRender } from "@tanstack/react-table";
|
|
6
6
|
import { useLegacyTable, getCoreRowModel, getSortedRowModel } from "@tanstack/react-table/legacy";
|
|
7
7
|
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
|
|
8
|
-
import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox } from "lucide-react";
|
|
8
|
+
import { RefreshCw, Loader2, SlidersHorizontal, MoreHorizontal, Circle, Filter, ChevronUp, ChevronDown, ChevronsUpDown, Check, Inbox, Save } from "lucide-react";
|
|
9
9
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../primitives/table.js";
|
|
10
10
|
import { Button } from "../primitives/button.js";
|
|
11
11
|
import { Checkbox } from "../primitives/checkbox.js";
|
|
@@ -46,6 +46,7 @@ import { readVersionedPreference, writeVersionedPreference, clearVersionedPrefer
|
|
|
46
46
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
47
47
|
import { flash } from "./FlashMessages.js";
|
|
48
48
|
import { useConfirmDialog } from "./confirm-dialog/index.js";
|
|
49
|
+
import { surfaceRecordConflict } from "./conflicts/index.js";
|
|
49
50
|
import { ComponentReplacementHandles } from "@open-mercato/shared/modules/widgets/component-registry";
|
|
50
51
|
import { dataTableExtensionSpotId, extensionSpotChildId } from "@open-mercato/shared/modules/widgets/extension-points";
|
|
51
52
|
import { insertByInjectionPlacement } from "@open-mercato/shared/modules/widgets/injection-position";
|
|
@@ -77,6 +78,7 @@ import {
|
|
|
77
78
|
import { CSS } from "@dnd-kit/utilities";
|
|
78
79
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
79
80
|
import { clearAllPerspectiveState, PERSPECTIVE_COOKIE_PREFIX, PERSPECTIVE_STORAGE_PREFIX } from "./perspectiveState.js";
|
|
81
|
+
import { diffPerspectiveSettings } from "./perspectiveDirty.js";
|
|
80
82
|
const logger = createLogger("ui").child({ component: "DataTable" });
|
|
81
83
|
let refreshScheduled = false;
|
|
82
84
|
function scheduleRouterRefresh(router) {
|
|
@@ -141,6 +143,7 @@ const EXPORT_LABELS = {
|
|
|
141
143
|
};
|
|
142
144
|
const EMPTY_FILTER_DEFS = [];
|
|
143
145
|
const EMPTY_FILTER_VALUES = Object.freeze({});
|
|
146
|
+
const EMPTY_VIEW_SETTINGS = Object.freeze({});
|
|
144
147
|
const STICKY_RIGHT_SHADOW_CLASS = "md:before:absolute md:before:inset-y-0 md:before:-left-2 md:before:w-2 md:before:bg-gradient-to-l md:before:from-foreground/8 md:before:to-transparent md:before:pointer-events-none";
|
|
145
148
|
const STICKY_LEFT_SHADOW_CLASS = "md:after:absolute md:after:inset-y-0 md:after:-right-2 md:after:w-2 md:after:bg-gradient-to-r md:after:from-foreground/8 md:after:to-transparent md:after:pointer-events-none";
|
|
146
149
|
function collectUniqueById(entries, warningScope) {
|
|
@@ -757,6 +760,9 @@ function DataTable({
|
|
|
757
760
|
entityIds,
|
|
758
761
|
exporter,
|
|
759
762
|
perspective,
|
|
763
|
+
onColumnsDirtyChange,
|
|
764
|
+
viewApiRef,
|
|
765
|
+
showSaveViewButton = false,
|
|
760
766
|
embedded = false,
|
|
761
767
|
onCustomFieldFilterFieldsetChange,
|
|
762
768
|
customFieldFilterKeyExtras,
|
|
@@ -822,8 +828,11 @@ function DataTable({
|
|
|
822
828
|
const perspectiveEnabled = Boolean(perspectiveTableId);
|
|
823
829
|
const initialSnapshotRef = React.useRef(null);
|
|
824
830
|
const snapshotHydratedTableRef = React.useRef(null);
|
|
825
|
-
const
|
|
826
|
-
const mergedInitialSettings =
|
|
831
|
+
const initialSettingsSource = perspectiveConfig?.initialState?.initialSettings ?? null;
|
|
832
|
+
const mergedInitialSettings = React.useMemo(
|
|
833
|
+
() => sanitizePerspectiveSettings(initialSettingsSource),
|
|
834
|
+
[initialSettingsSource]
|
|
835
|
+
);
|
|
827
836
|
const initialActiveId = perspectiveConfig?.initialState?.activePerspectiveId ?? null;
|
|
828
837
|
const [isPerspectiveOpen, setPerspectiveOpen] = React.useState(false);
|
|
829
838
|
const [isAdvancedFilterOpen, setAdvancedFilterOpen] = React.useState(false);
|
|
@@ -838,6 +847,13 @@ function DataTable({
|
|
|
838
847
|
const [deletingIds, setDeletingIds] = React.useState([]);
|
|
839
848
|
const [roleClearingIds, setRoleClearingIds] = React.useState([]);
|
|
840
849
|
const [perspectiveApiMissing, setPerspectiveApiMissing] = React.useState(false);
|
|
850
|
+
const [viewBaseline, setViewBaselineState] = React.useState(() => mergedInitialSettings ?? {});
|
|
851
|
+
const viewBaselineInitializedRef = React.useRef(Boolean(mergedInitialSettings));
|
|
852
|
+
const setViewBaseline = React.useCallback((settings) => {
|
|
853
|
+
const initialized = viewBaselineInitializedRef.current;
|
|
854
|
+
viewBaselineInitializedRef.current = true;
|
|
855
|
+
setViewBaselineState((previous) => initialized && diffPerspectiveSettings(previous, settings).length === 0 ? previous : settings);
|
|
856
|
+
}, []);
|
|
841
857
|
const perspectiveFeatureQuery = useQuery({
|
|
842
858
|
queryKey: ["feature-check", "perspectives"],
|
|
843
859
|
enabled: perspectiveEnabled,
|
|
@@ -884,9 +900,12 @@ function DataTable({
|
|
|
884
900
|
setPerspectiveOpen(false);
|
|
885
901
|
}
|
|
886
902
|
}, [canUsePerspectives, isPerspectiveOpen]);
|
|
903
|
+
const initialSettingsSeededTableRef = React.useRef(null);
|
|
887
904
|
React.useEffect(() => {
|
|
888
905
|
if (!perspectiveTableId) return;
|
|
889
906
|
if (!mergedInitialSettings) return;
|
|
907
|
+
if (initialSettingsSeededTableRef.current === perspectiveTableId) return;
|
|
908
|
+
initialSettingsSeededTableRef.current = perspectiveTableId;
|
|
890
909
|
const snapshot = {
|
|
891
910
|
perspectiveId: initialActiveId,
|
|
892
911
|
settings: mergedInitialSettings,
|
|
@@ -894,7 +913,8 @@ function DataTable({
|
|
|
894
913
|
};
|
|
895
914
|
writePerspectiveSnapshot(perspectiveTableId, snapshot);
|
|
896
915
|
initialSnapshotRef.current = snapshot;
|
|
897
|
-
|
|
916
|
+
setViewBaseline(mergedInitialSettings);
|
|
917
|
+
}, [perspectiveTableId, mergedInitialSettings, initialActiveId, setViewBaseline]);
|
|
898
918
|
const perspectiveQuery = useQuery({
|
|
899
919
|
queryKey: ["table-perspectives", perspectiveTableId],
|
|
900
920
|
queryFn: async () => {
|
|
@@ -1276,6 +1296,9 @@ function DataTable({
|
|
|
1276
1296
|
}, [columnOrder, columnVisibility, columnSizing, sorting, filterValues, searchValue, advancedFilter]);
|
|
1277
1297
|
const applyPerspectiveSettings = React.useCallback((settings, nextId, options) => {
|
|
1278
1298
|
const normalized = sanitizePerspectiveSettings(settings) ?? {};
|
|
1299
|
+
setViewBaseline(
|
|
1300
|
+
options?.preserveAdvancedFilter ? { ...normalized, filters: getCurrentSettings().filters } : normalized
|
|
1301
|
+
);
|
|
1279
1302
|
if (normalized.columnOrder && normalized.columnOrder.length) {
|
|
1280
1303
|
setColumnOrder(normalized.columnOrder);
|
|
1281
1304
|
} else {
|
|
@@ -1335,7 +1358,7 @@ function DataTable({
|
|
|
1335
1358
|
initialSnapshotRef.current = null;
|
|
1336
1359
|
}
|
|
1337
1360
|
}
|
|
1338
|
-
}, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter]);
|
|
1361
|
+
}, [onFiltersApply, onSearchChange, onSortingChange, perspectiveTableId, table, advancedFilter, getCurrentSettings, setViewBaseline]);
|
|
1339
1362
|
const persistColumnSizingSnapshot = React.useCallback(() => {
|
|
1340
1363
|
if (!perspectiveTableId) return;
|
|
1341
1364
|
const sizing = columnSizingRef.current;
|
|
@@ -1602,6 +1625,117 @@ function DataTable({
|
|
|
1602
1625
|
settings: input.settings
|
|
1603
1626
|
});
|
|
1604
1627
|
}, [savePerspectiveMutation, activePersonalPerspectiveId]);
|
|
1628
|
+
const defaultColumnOrderIds = React.useMemo(
|
|
1629
|
+
() => table.getAllLeafColumns().map((column) => column.id),
|
|
1630
|
+
[table, mergedColumns]
|
|
1631
|
+
);
|
|
1632
|
+
const viewApiRequested = Boolean(onColumnsDirtyChange || viewApiRef || showSaveViewButton);
|
|
1633
|
+
const currentViewSettings = React.useMemo(
|
|
1634
|
+
() => viewApiRequested ? getCurrentSettings() : EMPTY_VIEW_SETTINGS,
|
|
1635
|
+
[viewApiRequested, getCurrentSettings]
|
|
1636
|
+
);
|
|
1637
|
+
const viewDirtyState = React.useMemo(() => {
|
|
1638
|
+
if (!viewApiRequested || !perspectiveEnabled || !viewBaselineInitializedRef.current) {
|
|
1639
|
+
return {
|
|
1640
|
+
isDirty: false,
|
|
1641
|
+
changedKeys: [],
|
|
1642
|
+
changedCount: 0,
|
|
1643
|
+
activePerspectiveId,
|
|
1644
|
+
canSaveToActiveView: false
|
|
1645
|
+
};
|
|
1646
|
+
}
|
|
1647
|
+
const changedKeys = diffPerspectiveSettings(viewBaseline, currentViewSettings, {
|
|
1648
|
+
defaultColumnOrder: defaultColumnOrderIds
|
|
1649
|
+
});
|
|
1650
|
+
return {
|
|
1651
|
+
isDirty: changedKeys.length > 0,
|
|
1652
|
+
changedKeys,
|
|
1653
|
+
changedCount: changedKeys.length,
|
|
1654
|
+
activePerspectiveId,
|
|
1655
|
+
canSaveToActiveView: Boolean(activePersonalPerspectiveId)
|
|
1656
|
+
};
|
|
1657
|
+
}, [
|
|
1658
|
+
viewApiRequested,
|
|
1659
|
+
perspectiveEnabled,
|
|
1660
|
+
viewBaseline,
|
|
1661
|
+
currentViewSettings,
|
|
1662
|
+
defaultColumnOrderIds,
|
|
1663
|
+
activePerspectiveId,
|
|
1664
|
+
activePersonalPerspectiveId
|
|
1665
|
+
]);
|
|
1666
|
+
React.useEffect(() => {
|
|
1667
|
+
if (!viewApiRequested || !perspectiveEnabled || viewBaselineInitializedRef.current) return;
|
|
1668
|
+
setViewBaseline(currentViewSettings);
|
|
1669
|
+
}, [viewApiRequested, perspectiveEnabled, currentViewSettings, setViewBaseline]);
|
|
1670
|
+
const viewDirtyStateRef = React.useRef(viewDirtyState);
|
|
1671
|
+
const onColumnsDirtyChangeRef = React.useRef(onColumnsDirtyChange);
|
|
1672
|
+
React.useLayoutEffect(() => {
|
|
1673
|
+
viewDirtyStateRef.current = viewDirtyState;
|
|
1674
|
+
onColumnsDirtyChangeRef.current = onColumnsDirtyChange;
|
|
1675
|
+
}, [viewDirtyState, onColumnsDirtyChange]);
|
|
1676
|
+
const lastDirtySignatureRef = React.useRef(null);
|
|
1677
|
+
React.useEffect(() => {
|
|
1678
|
+
const notify = onColumnsDirtyChangeRef.current;
|
|
1679
|
+
if (!notify || !perspectiveEnabled) return;
|
|
1680
|
+
const signature = JSON.stringify([
|
|
1681
|
+
viewDirtyState.isDirty,
|
|
1682
|
+
viewDirtyState.changedKeys,
|
|
1683
|
+
viewDirtyState.activePerspectiveId,
|
|
1684
|
+
viewDirtyState.canSaveToActiveView
|
|
1685
|
+
]);
|
|
1686
|
+
if (lastDirtySignatureRef.current === signature) return;
|
|
1687
|
+
lastDirtySignatureRef.current = signature;
|
|
1688
|
+
notify(viewDirtyState);
|
|
1689
|
+
}, [viewDirtyState, perspectiveEnabled]);
|
|
1690
|
+
const saveCurrentView = React.useCallback(async (input) => {
|
|
1691
|
+
if (!perspectiveTableId) return { ok: false, reason: "perspectives-disabled" };
|
|
1692
|
+
if (perspectivePermissions === void 0) return { ok: false, reason: "not-ready" };
|
|
1693
|
+
if (!canUsePerspectives) return { ok: false, reason: "perspectives-disabled" };
|
|
1694
|
+
const targetId = input?.perspectiveId !== void 0 ? input.perspectiveId : activePersonalPerspectiveId;
|
|
1695
|
+
const existing = targetId ? perspectiveData?.perspectives.find((item) => item.id === targetId) ?? null : null;
|
|
1696
|
+
const name = (input?.name ?? existing?.name ?? "").trim();
|
|
1697
|
+
if (!name) return { ok: false, reason: "name-required" };
|
|
1698
|
+
try {
|
|
1699
|
+
const saved = await savePerspectiveMutation.mutateAsync({
|
|
1700
|
+
name,
|
|
1701
|
+
isDefault: input?.isDefault ?? existing?.isDefault ?? false,
|
|
1702
|
+
applyToRoles: [],
|
|
1703
|
+
setRoleDefault: false,
|
|
1704
|
+
perspectiveId: targetId ?? null
|
|
1705
|
+
});
|
|
1706
|
+
return { ok: true, perspectiveId: saved?.perspective?.id ?? null };
|
|
1707
|
+
} catch (error2) {
|
|
1708
|
+
return { ok: false, reason: "failed", error: error2 };
|
|
1709
|
+
}
|
|
1710
|
+
}, [
|
|
1711
|
+
canUsePerspectives,
|
|
1712
|
+
perspectivePermissions,
|
|
1713
|
+
perspectiveTableId,
|
|
1714
|
+
activePersonalPerspectiveId,
|
|
1715
|
+
perspectiveData,
|
|
1716
|
+
savePerspectiveMutation
|
|
1717
|
+
]);
|
|
1718
|
+
React.useImperativeHandle(viewApiRef, () => ({
|
|
1719
|
+
getCurrentSettings: () => getCurrentSettings(),
|
|
1720
|
+
getDirtyState: () => viewDirtyStateRef.current,
|
|
1721
|
+
saveCurrentView,
|
|
1722
|
+
openViewsSidebar: () => setPerspectiveOpen(true)
|
|
1723
|
+
}), [getCurrentSettings, saveCurrentView]);
|
|
1724
|
+
const handleSaveViewClick = React.useCallback(async () => {
|
|
1725
|
+
const result = await saveCurrentView();
|
|
1726
|
+
if (result.ok) {
|
|
1727
|
+
flash(t("ui.dataTable.saveView.success", "View saved"), "success");
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (result.reason === "name-required") {
|
|
1731
|
+
setPerspectiveOpen(true);
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
if (result.reason === "failed") {
|
|
1735
|
+
if (surfaceRecordConflict(result.error, t)) return;
|
|
1736
|
+
flash(t("ui.dataTable.saveView.error", "Failed to save view"), "error");
|
|
1737
|
+
}
|
|
1738
|
+
}, [saveCurrentView, t]);
|
|
1605
1739
|
const handlePerspectiveDelete = React.useCallback(async (perspectiveId) => {
|
|
1606
1740
|
await deletePerspectiveMutation.mutateAsync({ perspectiveId });
|
|
1607
1741
|
}, [deletePerspectiveMutation]);
|
|
@@ -2090,9 +2224,29 @@ function DataTable({
|
|
|
2090
2224
|
}
|
|
2091
2225
|
)
|
|
2092
2226
|
] }) : null;
|
|
2093
|
-
const
|
|
2227
|
+
const saveViewButton = showSaveViewButton && canUsePerspectives ? /* @__PURE__ */ jsxs(
|
|
2228
|
+
Button,
|
|
2229
|
+
{
|
|
2230
|
+
type: "button",
|
|
2231
|
+
variant: "outline",
|
|
2232
|
+
size: "default",
|
|
2233
|
+
disabled: !viewDirtyState.isDirty || savePerspectiveMutation.isPending,
|
|
2234
|
+
onClick: () => {
|
|
2235
|
+
void handleSaveViewClick();
|
|
2236
|
+
},
|
|
2237
|
+
title: viewDirtyState.isDirty ? t("ui.dataTable.saveView.title", "Save the current view") : t("ui.dataTable.saveView.noChanges", "No unsaved changes"),
|
|
2238
|
+
"data-testid": "save-view-trigger",
|
|
2239
|
+
children: [
|
|
2240
|
+
savePerspectiveMutation.isPending ? /* @__PURE__ */ jsx(Loader2, { className: "h-4 w-4 animate-spin" }) : /* @__PURE__ */ jsx(Save, { className: "h-4 w-4" }),
|
|
2241
|
+
/* @__PURE__ */ jsx("span", { children: t("ui.dataTable.saveView.button", "Save view") }),
|
|
2242
|
+
viewDirtyState.changedCount > 0 ? /* @__PURE__ */ jsx("span", { className: "ml-1 inline-flex h-5 min-w-5 px-1.5 items-center justify-center rounded-full bg-muted-foreground/30 text-background text-xs", children: viewDirtyState.changedCount }) : null
|
|
2243
|
+
]
|
|
2244
|
+
}
|
|
2245
|
+
) : null;
|
|
2246
|
+
const leadingItems = advancedFilterButton || perspectiveButton || saveViewButton ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
|
|
2094
2247
|
advancedFilterButton,
|
|
2095
|
-
perspectiveButton
|
|
2248
|
+
perspectiveButton,
|
|
2249
|
+
saveViewButton
|
|
2096
2250
|
] }) : null;
|
|
2097
2251
|
const trailingItems = hasBulkButtons ? /* @__PURE__ */ jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [
|
|
2098
2252
|
selectedRows.length > 0 ? /* @__PURE__ */ jsx("span", { className: "text-sm text-muted-foreground", children: t("ui.dataTable.bulkAction.selectedCount", "{count} selected", { count: selectedRows.length }) }) : null,
|
|
@@ -2186,7 +2340,12 @@ function DataTable({
|
|
|
2186
2340
|
advancedFilter,
|
|
2187
2341
|
advancedFilterRuleCount,
|
|
2188
2342
|
isAdvancedFilterOpen,
|
|
2189
|
-
resolvedAdvancedFilterFields
|
|
2343
|
+
resolvedAdvancedFilterFields,
|
|
2344
|
+
showSaveViewButton,
|
|
2345
|
+
viewDirtyState,
|
|
2346
|
+
savePerspectiveMutation.isPending,
|
|
2347
|
+
handleSaveViewClick,
|
|
2348
|
+
t
|
|
2190
2349
|
]);
|
|
2191
2350
|
const hasTitle = title != null;
|
|
2192
2351
|
const hasActions = actions !== void 0 && actions !== null && actions !== false;
|