@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.
@@ -1,3 +1,3 @@
1
1
  Generated lucide registry with 145 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- [build:ui] found 398 entry points
2
+ [build:ui] found 399 entry points
3
3
  [build:ui] built successfully
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 initialSettingsFromConfig = sanitizePerspectiveSettings(perspectiveConfig?.initialState?.initialSettings ?? null);
826
- const mergedInitialSettings = initialSettingsFromConfig;
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
- }, [perspectiveTableId, mergedInitialSettings, initialActiveId]);
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 leadingItems = advancedFilterButton || perspectiveButton ? /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
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;