@proveanything/smartlinks-utils-ui 0.7.3 → 0.7.5

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.
@@ -727,6 +727,14 @@ interface RecordsAdminShellProps<TData = unknown> {
727
727
  csvSchema?: CsvSchema<TData>;
728
728
  classify?: (record: RecordSummary<TData>) => RecordStatus;
729
729
  defaultData?: () => TData;
730
+ /**
731
+ * Optional derivation for the label that represents an in-flight draft
732
+ * in the unsaved-changes tray. Receives the editor's current value and
733
+ * its scope; return a short, human-readable title (or `undefined` to
734
+ * defer to the built-in heuristic). Useful when your record shape nests
735
+ * the title under a custom key the heuristic doesn't know about.
736
+ */
737
+ deriveDraftLabel?: (value: TData, scope: ParsedRef) => string | undefined;
730
738
  /**
731
739
  * Which layouts the rail offers. Default `['list']`. When more than one is
732
740
  * supplied, a switcher appears above the list and the choice persists
@@ -1566,6 +1574,15 @@ interface UseRecordEditorArgs<T> {
1566
1574
  * back a concrete `recordId`.
1567
1575
  */
1568
1576
  createMode?: boolean;
1577
+ /**
1578
+ * Optional host-supplied derivation for the label this editor registers
1579
+ * with the unsaved-changes tray. Receives the in-flight value and the
1580
+ * scope; should return a short, human-readable title. When omitted (or
1581
+ * when it returns `undefined`), the hook falls back to a built-in heuristic
1582
+ * that scans common title-shaped fields (`title`, `display.title`,
1583
+ * `name`, `label`, `heading`, `question`, `slug`).
1584
+ */
1585
+ deriveDraftLabel?: (value: T, scope: ParsedRef) => string | undefined;
1569
1586
  }
1570
1587
  declare function useRecordEditor<T>(args: UseRecordEditorArgs<T>): EditorContext<T>;
1571
1588
 
@@ -2140,9 +2157,18 @@ interface Props<T> {
2140
2157
  error: Error | null;
2141
2158
  onBack: () => void;
2142
2159
  onSelect: (itemId: string) => void;
2160
+ /**
2161
+ * Set of keys (recordIds + scope refs) currently dirty in the shell-level
2162
+ * draft store. Used to paint a per-row pip on items the user has edited
2163
+ * but isn't currently looking at — mirrors the affordance the scope rail
2164
+ * gets via `RecordList.dirtyKeys`.
2165
+ */
2166
+ dirtyKeys?: ReadonlySet<string>;
2167
+ /** Subset of `dirtyKeys` whose last save attempt failed. */
2168
+ errorKeys?: ReadonlySet<string>;
2143
2169
  i18n: Pick<RecordsAdminI18n, 'backToScopes' | 'siblingsHeading' | 'noItemsTitle' | 'noItemsBody'>;
2144
2170
  }
2145
- declare function SiblingRail<T>({ items, selectedItemId, isLoading, error, onBack, onSelect, i18n, }: Props<T>): react_jsx_runtime.JSX.Element;
2171
+ declare function SiblingRail<T>({ items, selectedItemId, isLoading, error, onBack, onSelect, dirtyKeys, errorKeys, i18n, }: Props<T>): react_jsx_runtime.JSX.Element;
2146
2172
 
2147
2173
  interface ClipboardEntry<T = unknown> {
2148
2174
  value: T;
@@ -692,7 +692,8 @@ function useRecordEditor(args) {
692
692
  onSaveError,
693
693
  reseed = "always",
694
694
  initialFacetRule = null,
695
- createMode = false
695
+ createMode = false,
696
+ deriveDraftLabel
696
697
  } = args;
697
698
  const queryClient = useQueryClient();
698
699
  const draftStore = useDirtyDraftStore();
@@ -713,6 +714,7 @@ function useRecordEditor(args) {
713
714
  const [savedFacetRule, setSavedFacetRule] = useState(
714
715
  initialDraft ? initialDraft.baselineFacetRule : initialFacetRule
715
716
  );
717
+ const [userInteracted, setUserInteracted] = useState(!!initialDraft);
716
718
  const [optimisticSource, setOptimisticSource] = useState(null);
717
719
  const [isSaving, setIsSaving] = useState(false);
718
720
  const [saveError, setSaveError] = useState(null);
@@ -732,8 +734,18 @@ function useRecordEditor(args) {
732
734
  setFacetRule(initialFacetRule);
733
735
  setSavedFacetRule(initialFacetRule);
734
736
  setOptimisticSource(null);
737
+ setUserInteracted(false);
735
738
  }, [scope.raw, resolved.source, resolved.sourceRef]);
736
- const isDirty = !isEqual(value, savedSnapshot) || !isEqual(facetRule, savedFacetRule);
739
+ const valueDiff = !isEqual(value, savedSnapshot) || !isEqual(facetRule, savedFacetRule);
740
+ const isDirty = userInteracted && valueDiff;
741
+ const handleChange = useCallback((next) => {
742
+ setUserInteracted(true);
743
+ setValue(next);
744
+ }, []);
745
+ const handleFacetRuleChange = useCallback((next) => {
746
+ setUserInteracted(true);
747
+ setFacetRule(next);
748
+ }, []);
737
749
  const save = useCallback(async () => {
738
750
  const anchors = parsedRefToScope(scope);
739
751
  const hasAnchors = !!(anchors.productId || anchors.variantId || anchors.batchId || anchors.proofId);
@@ -817,6 +829,7 @@ function useRecordEditor(args) {
817
829
  const reset = useCallback(() => {
818
830
  setValue(savedSnapshot);
819
831
  setFacetRule(savedFacetRule);
832
+ setUserInteracted(false);
820
833
  draftStore.clearDraft(draftKey);
821
834
  }, [savedSnapshot, savedFacetRule]);
822
835
  const remove = useCallback(async () => {
@@ -826,15 +839,60 @@ function useRecordEditor(args) {
826
839
  draftStore.clearDraft(draftKey);
827
840
  onDeleted?.();
828
841
  }, [resolved.source, resolved.recordId]);
842
+ const prevDraftKeyRef = useRef(draftKey);
843
+ const prevScopeRawRef = useRef(scope.raw);
844
+ useEffect(() => {
845
+ const prevKey = prevDraftKeyRef.current;
846
+ const prevScopeRaw = prevScopeRawRef.current;
847
+ if (prevKey && prevKey !== draftKey && prevScopeRaw === scope.raw) {
848
+ const stale = draftStore.get(prevKey);
849
+ if (stale) draftStore.clearDraft(prevKey);
850
+ }
851
+ prevDraftKeyRef.current = draftKey;
852
+ prevScopeRawRef.current = scope.raw;
853
+ }, [draftKey, scope.raw]);
829
854
  useEffect(() => {
830
855
  if (!isDirty) {
831
856
  return;
832
857
  }
833
858
  const anchors = parsedRefToScope(scope);
834
859
  const saveKind = resolved.recordId && resolved.source === "self" ? "update" : createMode ? "create" : "upsert";
860
+ const deriveLabel = () => {
861
+ if (deriveDraftLabel) {
862
+ try {
863
+ const custom = deriveDraftLabel(value, scope);
864
+ if (typeof custom === "string" && custom.trim()) return custom.trim();
865
+ } catch {
866
+ }
867
+ }
868
+ const KEYS = ["title", "name", "label", "heading", "question", "slug"];
869
+ const pickString = (obj) => {
870
+ if (!obj || typeof obj !== "object") return void 0;
871
+ for (const key of KEYS) {
872
+ const raw = obj[key];
873
+ if (typeof raw === "string" && raw.trim()) return raw.trim();
874
+ }
875
+ return void 0;
876
+ };
877
+ const v = value;
878
+ const top = pickString(v);
879
+ if (top) return top;
880
+ if (v && typeof v === "object") {
881
+ for (const wrapper of ["display", "content", "meta", "data"]) {
882
+ const nested = pickString(v[wrapper]);
883
+ if (nested) return nested;
884
+ }
885
+ }
886
+ if (scope.raw?.startsWith("item:")) return "Untitled item";
887
+ if (scope.kind === "rule") return "Rule";
888
+ if (scope.kind && scope.kind !== "collection") {
889
+ return scope.kind.charAt(0).toUpperCase() + scope.kind.slice(1);
890
+ }
891
+ return "Default";
892
+ };
835
893
  draftStore.upsertDraft({
836
894
  key: draftKey,
837
- label: scope.raw || "Default",
895
+ label: deriveLabel(),
838
896
  context: scope.kind,
839
897
  scopeRaw: scope.raw,
840
898
  recordId: resolved.recordId,
@@ -858,7 +916,7 @@ function useRecordEditor(args) {
858
916
  const cannotSaveReason = !ruleValid ? "Pick at least one value for every facet in the rule before saving." : void 0;
859
917
  return {
860
918
  value,
861
- onChange: setValue,
919
+ onChange: handleChange,
862
920
  source: effectiveSource,
863
921
  recordId: resolved.recordId,
864
922
  parentValue: resolved.parentValue,
@@ -873,7 +931,7 @@ function useRecordEditor(args) {
873
931
  isSaving,
874
932
  saveError,
875
933
  facetRule,
876
- onFacetRuleChange: setFacetRule
934
+ onFacetRuleChange: handleFacetRuleChange
877
935
  };
878
936
  }
879
937
  var RT_KEY = (recordType) => recordType ?? "_default";
@@ -3722,6 +3780,8 @@ function SiblingRail({
3722
3780
  error,
3723
3781
  onBack,
3724
3782
  onSelect,
3783
+ dirtyKeys,
3784
+ errorKeys,
3725
3785
  i18n
3726
3786
  }) {
3727
3787
  return /* @__PURE__ */ jsxs("div", { className: "ra-sibling-rail", children: [
@@ -3745,17 +3805,36 @@ function SiblingRail({
3745
3805
  !isLoading && !error && items.length > 0 && /* @__PURE__ */ jsx("ul", { className: "ra-sibling-list", children: items.map((item) => {
3746
3806
  const id = item.itemId ?? "";
3747
3807
  const selected = selectedItemId === id;
3748
- return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx(
3808
+ const isDirty = !!(id && dirtyKeys?.has(id) || item.ref && dirtyKeys?.has(item.ref));
3809
+ const hasError = !!(id && errorKeys?.has(id) || item.ref && errorKeys?.has(item.ref));
3810
+ return /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsxs(
3749
3811
  "button",
3750
3812
  {
3751
3813
  type: "button",
3752
3814
  onClick: () => onSelect(id),
3753
3815
  className: "ra-row",
3754
3816
  "data-selected": selected,
3755
- children: /* @__PURE__ */ jsxs("div", { className: "ra-row-body", children: [
3756
- /* @__PURE__ */ jsx("div", { className: "ra-row-title", children: item.label }),
3757
- item.subtitle && /* @__PURE__ */ jsx("div", { className: "ra-row-sub", children: item.subtitle })
3758
- ] })
3817
+ children: [
3818
+ /* @__PURE__ */ jsxs("div", { className: "ra-row-body", children: [
3819
+ /* @__PURE__ */ jsx("div", { className: "ra-row-title", children: item.label }),
3820
+ item.subtitle && /* @__PURE__ */ jsx("div", { className: "ra-row-sub", children: item.subtitle })
3821
+ ] }),
3822
+ hasError ? /* @__PURE__ */ jsx(
3823
+ "span",
3824
+ {
3825
+ className: "ra-error-pip",
3826
+ title: "Save failed",
3827
+ "aria-label": "Save failed"
3828
+ }
3829
+ ) : isDirty ? /* @__PURE__ */ jsx(
3830
+ "span",
3831
+ {
3832
+ className: "ra-dirty-pip",
3833
+ title: "Unsaved changes",
3834
+ "aria-label": "Unsaved changes"
3835
+ }
3836
+ ) : null
3837
+ ]
3759
3838
  }
3760
3839
  ) }, item.ref);
3761
3840
  }) })
@@ -4202,8 +4281,23 @@ var UnsavedTray = ({
4202
4281
  const wrapRef = useRef(null);
4203
4282
  const SI = SaveIcon ?? Save;
4204
4283
  const DI = DiscardIcon ?? Undo2;
4205
- const total = drafts.length;
4206
- const errors = drafts.filter((d) => d.status === "error").length;
4284
+ const uniqueDrafts = useMemo(() => {
4285
+ const byBucket = /* @__PURE__ */ new Map();
4286
+ for (const d of drafts) {
4287
+ const bucket = d.recordId || d.scopeRaw || d.key;
4288
+ const existing = byBucket.get(bucket);
4289
+ if (!existing) {
4290
+ byBucket.set(bucket, d);
4291
+ continue;
4292
+ }
4293
+ const existingSynthetic = existing.key.startsWith("draft:");
4294
+ const incomingSynthetic = d.key.startsWith("draft:");
4295
+ if (existingSynthetic && !incomingSynthetic) byBucket.set(bucket, d);
4296
+ }
4297
+ return Array.from(byBucket.values()).sort((a, b) => a.order - b.order);
4298
+ }, [drafts]);
4299
+ const total = uniqueDrafts.length;
4300
+ const errors = uniqueDrafts.filter((d) => d.status === "error").length;
4207
4301
  const isSingle = total === 1;
4208
4302
  useEffect(() => {
4209
4303
  if (!open) return;
@@ -4214,7 +4308,7 @@ var UnsavedTray = ({
4214
4308
  return () => window.removeEventListener("mousedown", onDoc);
4215
4309
  }, [open]);
4216
4310
  if (total === 0) return null;
4217
- const countLabel = isSingle ? drafts[0].label || "this record" : countTemplate.replace("{n}", String(total));
4311
+ const countLabel = isSingle ? uniqueDrafts[0].label || "this record" : countTemplate.replace("{n}", String(total));
4218
4312
  return /* @__PURE__ */ jsxs(
4219
4313
  "div",
4220
4314
  {
@@ -4284,7 +4378,7 @@ var UnsavedTray = ({
4284
4378
  }
4285
4379
  )
4286
4380
  ] }),
4287
- open && !isSingle && /* @__PURE__ */ jsx("div", { className: "ra-unsaved-popover", role: "menu", children: drafts.map((d) => /* @__PURE__ */ jsxs(
4381
+ open && !isSingle && /* @__PURE__ */ jsx("div", { className: "ra-unsaved-popover", role: "menu", children: uniqueDrafts.map((d) => /* @__PURE__ */ jsxs(
4288
4382
  "button",
4289
4383
  {
4290
4384
  type: "button",
@@ -4593,6 +4687,11 @@ styleInject(".ra-shell {\n color: hsl(var(--ra-text));\n background: hsl(var(-
4593
4687
  var TOP_LEVEL_SCOPES = ["collection", "rule", "product"];
4594
4688
  var WARNED_FACET_DEPRECATED = false;
4595
4689
  var DRAFT_ID = "__draft__";
4690
+ var isDraftId = (id) => !!id && (id === DRAFT_ID || id.startsWith("draft:"));
4691
+ var mintDraftItemId = () => {
4692
+ const rand = typeof crypto !== "undefined" && "randomUUID" in crypto ? crypto.randomUUID() : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
4693
+ return `draft:${rand}`;
4694
+ };
4596
4695
  var productItemToSummary = (p) => {
4597
4696
  const ref = buildRef({ productId: p.id });
4598
4697
  return {
@@ -4674,7 +4773,8 @@ function RecordsAdminShellInner(props) {
4674
4773
  renderItemEmpty,
4675
4774
  collectionRailMode = "siblings",
4676
4775
  // Deep linking
4677
- deepLink
4776
+ deepLink,
4777
+ deriveDraftLabel
4678
4778
  } = props;
4679
4779
  const i18n = { ...DEFAULT_I18N, ...i18nOverride ?? {} };
4680
4780
  const icons = useMemo(() => mergeIcons(iconsOverride), [iconsOverride]);
@@ -4985,7 +5085,7 @@ function RecordsAdminShellInner(props) {
4985
5085
  // fall back to anchor-based `match()` at the collection root and the
4986
5086
  // editor would mount against `null` data — surfacing as the host's
4987
5087
  // "Select a {noun} from the list to edit" placeholder.
4988
- recordId: isCollection && selectedItemId && selectedItemId !== DRAFT_ID ? selectedItemId : selectedRecordId && selectedRecordId !== DRAFT_ID ? selectedRecordId : void 0,
5088
+ recordId: isCollection && selectedItemId && !isDraftId(selectedItemId) ? selectedItemId : selectedRecordId && !isDraftId(selectedRecordId) ? selectedRecordId : void 0,
4989
5089
  supportedScopes: supportedForResolution,
4990
5090
  enabled: !!editingTargetScope
4991
5091
  });
@@ -5040,7 +5140,8 @@ function RecordsAdminShellInner(props) {
5040
5140
  setSelectedBatchId(void 0);
5041
5141
  }
5042
5142
  refetchAll();
5043
- }
5143
+ },
5144
+ deriveDraftLabel
5044
5145
  });
5045
5146
  useUnsavedGuard({
5046
5147
  isDirty: editorCtx.isDirty,
@@ -5311,7 +5412,7 @@ function RecordsAdminShellInner(props) {
5311
5412
  const onItemCreate = useCallback(() => {
5312
5413
  if (!isCollection) return;
5313
5414
  void runWithGuard(() => {
5314
- const id = generateItemId ? generateItemId() : DRAFT_ID;
5415
+ const id = generateItemId ? generateItemId() : mintDraftItemId();
5315
5416
  setSelectedItemId(id);
5316
5417
  onTelemetry?.({ type: "item.create", recordType, scopeRef: baseScopeRef });
5317
5418
  deepLinkState.emit({ recordId: null, scope: baseScopeRef || null }, "record.open");
@@ -5319,7 +5420,7 @@ function RecordsAdminShellInner(props) {
5319
5420
  }, [isCollection, runWithGuard, generateItemId, onTelemetry, recordType, baseScopeRef, deepLinkState, buildItemUrlValue]);
5320
5421
  const onItemDelete = useCallback(async (itemId) => {
5321
5422
  if (!isCollection) return;
5322
- if (itemId === DRAFT_ID) return;
5423
+ if (isDraftId(itemId)) return;
5323
5424
  if (onBeforeDelete) {
5324
5425
  const ok = await onBeforeDelete(editingScope ?? parseRef(""));
5325
5426
  if (!ok) return;
@@ -5400,7 +5501,7 @@ function RecordsAdminShellInner(props) {
5400
5501
  i18n: { previewAs: i18n.previewAs, previewAsDefault: i18n.previewAsDefault }
5401
5502
  }
5402
5503
  ) : null;
5403
- const itemNav = isCollection && selectedItemId && itemPosition ? /* @__PURE__ */ jsx(
5504
+ const itemNav = isCollection && selectedItemId && itemPosition && ruleWizardStep === null ? /* @__PURE__ */ jsx(
5404
5505
  EditorItemNav,
5405
5506
  {
5406
5507
  label: editorHeaderLabel,
@@ -5574,7 +5675,7 @@ function RecordsAdminShellInner(props) {
5574
5675
  }, []);
5575
5676
  const onRuleWizardCreateItem = useCallback(() => {
5576
5677
  if (!isCollection) return;
5577
- const id = generateItemId ? generateItemId() : DRAFT_ID;
5678
+ const id = generateItemId ? generateItemId() : mintDraftItemId();
5578
5679
  setSelectedItemId(id);
5579
5680
  }, [isCollection, generateItemId]);
5580
5681
  const hasGlobalRecord = useMemo(
@@ -5824,7 +5925,7 @@ function RecordsAdminShellInner(props) {
5824
5925
  className: "flex-1 grid border-t overflow-hidden",
5825
5926
  style: { gridTemplateColumns: "minmax(260px, 320px) 1fr", borderColor: "hsl(var(--ra-border))", marginTop: "0.75rem" },
5826
5927
  children: [
5827
- /* @__PURE__ */ jsx("aside", { className: "border-r overflow-hidden flex flex-col", style: { borderColor: "hsl(var(--ra-border))", background: "hsl(var(--ra-surface))" }, children: isCollection && selectedItemId && collectionRailMode === "siblings" ? /* @__PURE__ */ jsx(
5928
+ /* @__PURE__ */ jsx("aside", { className: "border-r overflow-hidden flex flex-col", style: { borderColor: "hsl(var(--ra-border))", background: "hsl(var(--ra-surface))" }, children: isCollection && selectedItemId && collectionRailMode === "siblings" && ruleWizardStep === null ? /* @__PURE__ */ jsx(
5828
5929
  SiblingRail,
5829
5930
  {
5830
5931
  items: collectionItems.items,
@@ -5833,6 +5934,8 @@ function RecordsAdminShellInner(props) {
5833
5934
  error: collectionItems.error,
5834
5935
  onBack: onItemBack,
5835
5936
  onSelect: onItemOpen,
5937
+ dirtyKeys,
5938
+ errorKeys,
5836
5939
  i18n
5837
5940
  }
5838
5941
  ) : /* @__PURE__ */ jsxs(Fragment, { children: [