@ai-matrx/records-ui 0.82.0 → 0.83.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/dist/index.cjs CHANGED
@@ -189,6 +189,8 @@ __export(src_exports, {
189
189
  VIEW_LAYOUTS: () => VIEW_LAYOUTS,
190
190
  VIEW_NOT_SAVED_YET: () => VIEW_NOT_SAVED_YET,
191
191
  VIEW_TABLE: () => VIEW_TABLE,
192
+ VISIBILITY_LANES: () => VISIBILITY_LANES,
193
+ VISIBILITY_LANE_TITLE: () => VISIBILITY_LANE_TITLE,
192
194
  ViewBar: () => ViewBar,
193
195
  ViewSwitcher: () => ViewSwitcher,
194
196
  WITHHELD_RECORD_LABEL: () => WITHHELD_RECORD_LABEL,
@@ -244,6 +246,7 @@ __export(src_exports, {
244
246
  isPlainFieldType: () => isPlainFieldType,
245
247
  isRelationFieldType: () => isRelationFieldType,
246
248
  isSignatureField: () => isSignatureField,
249
+ keptByTheApp: () => keptByTheApp,
247
250
  keyFor: () => keyFor,
248
251
  kindsWithNoChoice: () => kindsWithNoChoice,
249
252
  laneFor: () => laneFor,
@@ -279,6 +282,7 @@ __export(src_exports, {
279
282
  revokeConsequence: () => revokeConsequence,
280
283
  rowName: () => rowName,
281
284
  rowNameIn: () => rowNameIn,
285
+ saveViewPatch: () => saveViewPatch,
282
286
  scalarText: () => scalarText,
283
287
  shareUnavailableReason: () => shareUnavailableReason,
284
288
  specFromBlock: () => specFromBlock,
@@ -308,6 +312,7 @@ __export(src_exports, {
308
312
  viewDocument: () => viewDocument,
309
313
  viewFromRecord: () => viewFromRecord,
310
314
  viewPatchDocument: () => viewPatchDocument,
315
+ visibilityLaneFor: () => visibilityLaneFor,
311
316
  whatIsMissing: () => whatIsMissing,
312
317
  whatYouMayDo: () => whatYouMayDo,
313
318
  whatYouMayDoWithTable: () => whatYouMayDoWithTable,
@@ -2383,7 +2388,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2383
2388
  startedFrom.current = null;
2384
2389
  }, []);
2385
2390
  const writeCellNow = (0, import_react15.useCallback)(
2386
- async (rowId, key, value) => {
2391
+ async (rowId, key, value, base, resent = false) => {
2387
2392
  const id = cellKey(rowId, key);
2388
2393
  setOptimistic((held) => ({ ...held, [id]: value }));
2389
2394
  setSaving((held) => ({ ...held, [id]: true }));
@@ -2426,22 +2431,31 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2426
2431
  delete next[id];
2427
2432
  return next;
2428
2433
  });
2429
- if (written.error.code === "stale_write") versions.current.delete(rowId);
2434
+ const conflict = (0, import_core.asWriteConflict)(written.error, rowId);
2435
+ if (written.error.code === "stale_write") {
2436
+ if (conflict) rememberVersion(versions.current, rowId, conflict.current_version);
2437
+ else versions.current.delete(rowId);
2438
+ if (conflict && !resent && sameValue(conflict.contested_fields[key], base)) {
2439
+ await writeCellNow(rowId, key, value, base, true);
2440
+ return;
2441
+ }
2442
+ }
2430
2443
  setRefusals((held) => ({
2431
2444
  ...held,
2432
2445
  [id]: {
2433
2446
  error: written.error,
2434
- conflict: (0, import_core.asWriteConflict)(written.error, rowId),
2447
+ conflict,
2435
2448
  attempted: value
2436
2449
  }
2437
2450
  }));
2438
2451
  },
2452
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2439
2453
  [client, reload]
2440
2454
  );
2441
2455
  const writeCell = (0, import_react15.useCallback)(
2442
- (rowId, key, value) => {
2456
+ (rowId, key, value, base) => {
2443
2457
  const behind = queues.current.get(rowId) ?? Promise.resolve();
2444
- const next = behind.then(() => writeCellNow(rowId, key, value));
2458
+ const next = behind.then(() => writeCellNow(rowId, key, value, base));
2445
2459
  queues.current.set(
2446
2460
  rowId,
2447
2461
  next.then(
@@ -2459,7 +2473,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2459
2473
  const open = editing;
2460
2474
  if (!open) return;
2461
2475
  const value = typed.current;
2462
- if (!sameValue(startedFrom.current, value)) void writeCell(open.rowId, open.key, value);
2476
+ if (!sameValue(startedFrom.current, value)) void writeCell(open.rowId, open.key, value, startedFrom.current);
2463
2477
  if (!move) {
2464
2478
  setEditing(null);
2465
2479
  setDraft(null);
@@ -2488,7 +2502,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2488
2502
  (rowId, key) => {
2489
2503
  const held = refusals[cellKey(rowId, key)];
2490
2504
  if (!held) return;
2491
- void writeCell(rowId, key, held.attempted);
2505
+ void writeCell(rowId, key, held.attempted, held.conflict?.contested_fields[key]);
2492
2506
  },
2493
2507
  [refusals, writeCell]
2494
2508
  );
@@ -2510,6 +2524,33 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2510
2524
  },
2511
2525
  [reload]
2512
2526
  );
2527
+ const keepEditing = (0, import_react15.useCallback)(
2528
+ (rowId, key) => {
2529
+ const held = refusals[cellKey(rowId, key)];
2530
+ if (!held || !writable(rowId)) return;
2531
+ startedFrom.current = shownValue(rowId, key);
2532
+ typed.current = held.attempted;
2533
+ setDraft(held.attempted);
2534
+ setEditing({ rowId, key });
2535
+ },
2536
+ [refusals, writable, shownValue]
2537
+ );
2538
+ const discard = (0, import_react15.useCallback)((rowId, key) => {
2539
+ const id = cellKey(rowId, key);
2540
+ setRefusals((held) => {
2541
+ if (!held[id]) return held;
2542
+ const next = { ...held };
2543
+ delete next[id];
2544
+ return next;
2545
+ });
2546
+ setOptimistic((held) => {
2547
+ if (!(id in held)) return held;
2548
+ const next = { ...held };
2549
+ delete next[id];
2550
+ return next;
2551
+ });
2552
+ setEditing((open) => open && open.rowId === rowId && open.key === key ? null : open);
2553
+ }, []);
2513
2554
  const remove = (0, import_react15.useCallback)(
2514
2555
  async (rowId) => {
2515
2556
  setBusy(true);
@@ -2557,10 +2598,12 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2557
2598
  commit,
2558
2599
  setCell: (rowId, key, value) => {
2559
2600
  if (!writable(rowId)) return;
2560
- void writeCell(rowId, key, value);
2601
+ void writeCell(rowId, key, value, shownValue(rowId, key));
2561
2602
  },
2562
2603
  retry,
2563
2604
  keepTheirs,
2605
+ keepEditing,
2606
+ discard,
2564
2607
  remove,
2565
2608
  addRecord,
2566
2609
  rowError,
@@ -2629,13 +2672,28 @@ function GridCell({
2629
2672
  draft: editing.draft,
2630
2673
  onType: editing.type,
2631
2674
  onCommit: (move) => editing.commit(move),
2632
- onCancel: editing.cancel
2675
+ onCancel: editing.cancel,
2676
+ below: (
2677
+ // KEPT EDITING: the refusal stays beside the value it is about, so the
2678
+ // person reads what to change while changing it. Keep editing is the
2679
+ // state they are in, so only Discard is offered. It is INSIDE the open
2680
+ // cell, so pressing Discard is not "focus left the cell" — which would
2681
+ // commit the refused value one more time on its way out.
2682
+ refusal2 && !refusal2.conflict ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
2683
+ RefusalNotice,
2684
+ {
2685
+ error: refusal2.error,
2686
+ className: "py-1.5 text-left",
2687
+ onDiscard: () => editing.discard(row.id, field.key)
2688
+ }
2689
+ ) : null
2690
+ )
2633
2691
  }
2634
2692
  );
2635
2693
  }
2636
2694
  if (editorKindFor(field) === "checkbox") {
2637
2695
  const answered = value === true || value === false;
2638
- return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { "data-matrx-cell-control": "", className: "flex w-full items-center gap-1.5", children: [
2696
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { "data-matrx-cell-control": "", className: "flex w-full flex-wrap items-center gap-1.5", children: [
2639
2697
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
2640
2698
  import_design_system7.Checkbox,
2641
2699
  {
@@ -2648,10 +2706,7 @@ function GridCell({
2648
2706
  }
2649
2707
  ),
2650
2708
  answered ? null : /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className: "text-xs text-muted-foreground", title: "nobody has answered this yet", children: "\u2014" }),
2651
- refusal2 ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex items-center gap-1 rounded border border-destructive/40 px-1", children: [
2652
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(RefusalLine, { error: refusal2.error }),
2653
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", variant: "outline", onClick: () => editing?.retry(row.id, field.key), children: "Try again" })
2654
- ] }) : null
2709
+ refusal2 ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(CellRefusalNotice, { refusal: refusal2, field, row, editing, typed: false }) : null
2655
2710
  ] });
2656
2711
  }
2657
2712
  const openThisCell = (event) => {
@@ -2724,22 +2779,48 @@ function GridCell({
2724
2779
  ),
2725
2780
  onAskWhoChanged ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(WhoBadge, { field, row, onAskWhoChanged }) : null,
2726
2781
  state === "saving" ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className: "px-1 text-[10px] text-muted-foreground", children: "Saving\u2026" }) : null,
2727
- refusal2 ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex flex-col gap-0.5 rounded border border-destructive/40 p-1", children: [
2728
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(RefusalLine, { error: refusal2.error }),
2729
- refusal2.conflict ? /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "text-[11px]", children: [
2730
- "Theirs: ",
2731
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("strong", { children: formatTheirs(refusal2.conflict, field.key) })
2732
- ] }) : null,
2733
- /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex gap-1", children: [
2734
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", variant: "outline", onClick: () => editing?.retry(row.id, field.key), children: "Try again" }),
2735
- /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", variant: "ghost", onClick: () => editing?.keepTheirs(row.id, field.key), children: "Keep theirs" })
2736
- ] })
2737
- ] }) : null
2782
+ refusal2 ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(CellRefusalNotice, { refusal: refusal2, field, row, editing, typed: true }) : null
2738
2783
  ]
2739
2784
  }
2740
2785
  )
2741
2786
  );
2742
2787
  }
2788
+ function CellRefusalNotice({
2789
+ refusal: refusal2,
2790
+ field,
2791
+ row,
2792
+ editing,
2793
+ typed
2794
+ }) {
2795
+ if (refusal2.conflict) {
2796
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
2797
+ RefusalNotice,
2798
+ {
2799
+ error: refusal2.error,
2800
+ className: "py-1.5 text-left",
2801
+ actions: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex flex-col gap-1", "data-refusal-conflict": "", children: [
2802
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "text-[11px]", children: [
2803
+ "Theirs: ",
2804
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("strong", { children: formatTheirs(refusal2.conflict, field.key) })
2805
+ ] }),
2806
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex flex-wrap gap-1", children: [
2807
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", variant: "outline", onClick: () => editing?.retry(row.id, field.key), children: "Try again" }),
2808
+ /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", variant: "ghost", onClick: () => editing?.keepTheirs(row.id, field.key), children: "Keep theirs" })
2809
+ ] })
2810
+ ] })
2811
+ }
2812
+ );
2813
+ }
2814
+ return /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(
2815
+ RefusalNotice,
2816
+ {
2817
+ error: refusal2.error,
2818
+ className: "py-1.5 text-left",
2819
+ ...typed ? { onKeepEditing: () => editing?.keepEditing(row.id, field.key) } : {},
2820
+ onDiscard: () => editing?.discard(row.id, field.key)
2821
+ }
2822
+ );
2823
+ }
2743
2824
  function WhoBadge({
2744
2825
  field,
2745
2826
  row,
@@ -2792,7 +2873,8 @@ function OpenCell({
2792
2873
  draft,
2793
2874
  onType,
2794
2875
  onCommit,
2795
- onCancel
2876
+ onCancel,
2877
+ below
2796
2878
  }) {
2797
2879
  const multiline = takesANewline(field);
2798
2880
  const blurCommits = closesOnBlur(field);
@@ -2821,6 +2903,7 @@ function OpenCell({
2821
2903
  className: "flex w-full min-w-[10rem] flex-col gap-1",
2822
2904
  onDoubleClick: (event) => event.stopPropagation(),
2823
2905
  onKeyDown: (event) => {
2906
+ if (event.target?.closest?.("[data-refusal-notice]")) return;
2824
2907
  if (event.key === "Escape") {
2825
2908
  event.preventDefault();
2826
2909
  event.stopPropagation();
@@ -2846,7 +2929,8 @@ function OpenCell({
2846
2929
  blurCommits ? /* @__PURE__ */ (0, import_jsx_runtime10.jsx)("span", { className: "text-[10px] text-muted-foreground", children: multiline ? "\u23CE saves \xB7 \u21E7\u23CE new line \xB7 Esc cancels \xB7 Tab next" : "\u23CE saves \xB7 Esc cancels \xB7 Tab next" }) : /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex items-center gap-1", children: [
2847
2930
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", onClick: () => once(() => onCommit(null)), children: "Save" }),
2848
2931
  /* @__PURE__ */ (0, import_jsx_runtime10.jsx)(import_design_system7.Button, { size: "sm", variant: "ghost", onClick: () => once(onCancel), children: "Cancel" })
2849
- ] })
2932
+ ] }),
2933
+ below
2850
2934
  ]
2851
2935
  }
2852
2936
  );
@@ -6812,8 +6896,8 @@ function TableSettings({
6812
6896
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_design_system18.Button, { size: "sm", variant: "ghost", onClick: () => setAskingToRemove(null), children: "Keep it" })
6813
6897
  ] }) : null,
6814
6898
  /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("ul", { className: "divide-y rounded border", children: [
6815
- rows.map((field, index) => /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("li", { className: "flex items-center gap-2 px-2 py-1.5", children: [
6816
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "min-w-0 flex-1 truncate", children: fieldName(field) }),
6899
+ rows.map((field, index) => /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("li", { "data-settings-field": "", className: "flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1.5", children: [
6900
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { "data-settings-field-name": "", className: "min-w-[8rem] flex-1 break-words", children: fieldName(field) }),
6817
6901
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_design_system18.Badge, { variant: "outline", className: "text-[10px] font-normal", children: fieldTypeLabel(field) }),
6818
6902
  field.required ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-[10px] text-destructive", children: "required" }) : null,
6819
6903
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(
@@ -6870,9 +6954,9 @@ function TableSettings({
6870
6954
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "font-medium", children: "Proposed" }),
6871
6955
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "text-muted-foreground", children: proposals?.length ?? 0 })
6872
6956
  ] }),
6873
- proposals === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { className: "text-muted-foreground", children: "Nothing is listing proposals here yet: the store has no `custom.field_propose` door, so this panel would be inventing an empty queue. When the door lands, its rows appear in this list with the same accept and reject." }) : proposals.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { className: "text-muted-foreground", children: "No field is waiting for a decision." }) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("ul", { className: "divide-y rounded border", children: proposals.map((proposal) => /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("li", { className: "flex items-center gap-2 px-2 py-1.5", children: [
6874
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "min-w-0 flex-1 truncate", children: proposal.field.label || proposal.field.key }),
6875
- /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "min-w-0 flex-1 truncate text-muted-foreground", children: proposal.why }),
6957
+ proposals === void 0 ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { className: "text-muted-foreground", children: "Nothing is listing proposals here yet: the store has no `custom.field_propose` door, so this panel would be inventing an empty queue. When the door lands, its rows appear in this list with the same accept and reject." }) : proposals.length === 0 ? /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("p", { className: "text-muted-foreground", children: "No field is waiting for a decision." }) : /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("ul", { className: "divide-y rounded border", children: proposals.map((proposal) => /* @__PURE__ */ (0, import_jsx_runtime21.jsxs)("li", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 px-2 py-1.5", children: [
6958
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "min-w-[8rem] flex-1 break-words", children: proposal.field.label || proposal.field.key }),
6959
+ /* @__PURE__ */ (0, import_jsx_runtime21.jsx)("span", { className: "min-w-[8rem] flex-1 break-words text-muted-foreground", children: proposal.why }),
6876
6960
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_design_system18.Badge, { variant: "secondary", className: "text-[10px] font-normal", children: proposal.proposed_by }),
6877
6961
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_design_system18.Button, { size: "sm", variant: "outline", onClick: () => onAcceptProposal?.(proposal), children: "Accept" }),
6878
6962
  /* @__PURE__ */ (0, import_jsx_runtime21.jsx)(import_design_system18.Button, { size: "sm", variant: "ghost", onClick: () => onRejectProposal?.(proposal), children: "Reject" })
@@ -7047,7 +7131,39 @@ var import_design_system22 = require("@ai-matrx/design-system");
7047
7131
  // src/RecordForm.tsx
7048
7132
  var import_react41 = require("react");
7049
7133
  var import_react42 = require("@ai-matrx/records/react");
7134
+ var import_core7 = require("@ai-matrx/records/core");
7135
+
7136
+ // src/writeOnTheWinningVersion.ts
7050
7137
  var import_core6 = require("@ai-matrx/records/core");
7138
+ function sameStoredValue(left, right) {
7139
+ if (left === right) return true;
7140
+ const empty = (v) => v === null || v === void 0 || v === "";
7141
+ if (empty(left) || empty(right)) return empty(left) && empty(right);
7142
+ if (typeof left === "object" || typeof right === "object") {
7143
+ try {
7144
+ return JSON.stringify(left) === JSON.stringify(right);
7145
+ } catch {
7146
+ return false;
7147
+ }
7148
+ }
7149
+ return false;
7150
+ }
7151
+ async function updateOnTheWinningVersion(client, recordId, patch, expectedVersion, base) {
7152
+ const send = (expected) => client.recordUpdate({
7153
+ record_id: recordId,
7154
+ patch,
7155
+ ...expected === null ? {} : { expectedVersion: expected }
7156
+ });
7157
+ const first = await send(expectedVersion);
7158
+ if (first.ok) return first;
7159
+ const conflict = (0, import_core6.asWriteConflict)(first.error, recordId);
7160
+ if (!conflict) return first;
7161
+ const untouched = base === "always" || Object.keys(patch).every((key) => sameStoredValue(conflict.contested_fields[key], base[key]));
7162
+ if (!untouched) return first;
7163
+ return send(conflict.current_version);
7164
+ }
7165
+
7166
+ // src/RecordForm.tsx
7051
7167
  var import_design_system20 = require("@ai-matrx/design-system");
7052
7168
 
7053
7169
  // src/systemTable.ts
@@ -7235,13 +7351,38 @@ function RecordForm({
7235
7351
  const fields = (0, import_react42.useFields)(tableId, recordType);
7236
7352
  const existing = (0, import_react42.useRecord)(recordId ?? null);
7237
7353
  const mutation = (0, import_react42.useRecordMutation)();
7354
+ const client = (0, import_react42.useRecordsClient)();
7355
+ const [edit, setEdit] = (0, import_react41.useState)({
7356
+ saving: false,
7357
+ error: null,
7358
+ conflict: null
7359
+ });
7360
+ const said = {
7361
+ saving: mutation.saving || edit.saving,
7362
+ error: recordId ? edit.error : mutation.error,
7363
+ conflict: recordId ? edit.conflict : mutation.conflict,
7364
+ clearConflict: () => {
7365
+ mutation.clearConflict();
7366
+ setEdit((e) => ({ ...e, conflict: null }));
7367
+ }
7368
+ };
7238
7369
  const [draft, setDraft] = (0, import_react41.useState)({});
7239
7370
  const [touched, setTouched] = (0, import_react41.useState)(false);
7371
+ const [changed, setChanged] = (0, import_react41.useState)({});
7372
+ const [answered, setAnswered] = (0, import_react41.useState)(null);
7240
7373
  const loaded = useRecordVersion(recordId ?? null);
7241
7374
  const loadedVersion = loaded.version;
7375
+ const changedRef = (0, import_react41.useRef)({});
7376
+ changedRef.current = changed;
7242
7377
  (0, import_react41.useEffect)(() => {
7243
7378
  const document3 = existing.data?.document;
7244
- if (document3) setDraft({ ...document3 });
7379
+ if (!document3) return;
7380
+ const mine = changedRef.current;
7381
+ setDraft((held) => {
7382
+ const next = { ...document3 };
7383
+ for (const key of Object.keys(mine)) next[key] = held[key];
7384
+ return next;
7385
+ });
7245
7386
  }, [existing.data?.record_id, loadedVersion]);
7246
7387
  const document2 = (0, import_react41.useMemo)(() => {
7247
7388
  const out = {};
@@ -7252,22 +7393,61 @@ function RecordForm({
7252
7393
  return out;
7253
7394
  }, [draft]);
7254
7395
  const predicted = (0, import_react41.useMemo)(
7255
- () => fields.data ? (0, import_core6.predictWriteRefusals)({ fields: fields.data, document: document2, ...recordType ? { recordType } : {} }) : [],
7396
+ () => fields.data ? (0, import_core7.predictWriteRefusals)({ fields: fields.data, document: document2, ...recordType ? { recordType } : {} }) : [],
7256
7397
  [fields.data, document2, recordType]
7257
7398
  );
7258
7399
  if (fields.error) return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(RefusalNotice, { error: fields.error, className });
7259
7400
  if (existing.error) return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(RefusalNotice, { error: existing.error, className });
7401
+ const storeSaid = said.error && said.error !== answered ? said.error : null;
7402
+ const fieldProblems = predicted.filter((p) => p.field_key);
7403
+ const problemsShown = (key) => touched || changed[key] ? fieldProblems.filter((p) => p.field_key === key) : [];
7404
+ const storeRefusalIsOnAField = storeSaid !== null && !said.conflict && storeSaid.code === "refused_by_rule" && (fields.data ?? []).some((f) => problemsShown(f.key).length > 0);
7405
+ const stored = existing.data?.document;
7406
+ const focusField = (field) => {
7407
+ if (!field) return;
7408
+ globalThis.document?.getElementById(`field-${field.id}`)?.focus();
7409
+ };
7410
+ const keepEditing = (field) => {
7411
+ if (storeSaid) setAnswered(storeSaid);
7412
+ const first = field ?? (fields.data ?? []).find((f) => problemsShown(f.key).length > 0) ?? (fields.data ?? [])[0];
7413
+ focusField(first);
7414
+ };
7415
+ const discard = (field) => {
7416
+ if (storeSaid) setAnswered(storeSaid);
7417
+ if (field) {
7418
+ setDraft((d) => ({ ...d, [field.key]: stored ? stored[field.key] : void 0 }));
7419
+ setChanged((c) => {
7420
+ const next = { ...c };
7421
+ delete next[field.key];
7422
+ return next;
7423
+ });
7424
+ return;
7425
+ }
7426
+ setDraft(stored ? { ...stored } : {});
7427
+ setChanged({});
7428
+ setTouched(false);
7429
+ };
7260
7430
  const save = async () => {
7261
7431
  setTouched(true);
7432
+ setAnswered(null);
7262
7433
  if (recordId) {
7263
7434
  const version = loadedVersion;
7264
- const moved = await mutation.update({
7265
- record_id: recordId,
7266
- patch: document2,
7267
- ...version === null ? {} : { expectedVersion: version }
7435
+ if (Object.keys(changed).length === 0) {
7436
+ onSaved?.(recordId);
7437
+ return;
7438
+ }
7439
+ const patch = Object.fromEntries(Object.entries(document2).filter(([key]) => changed[key]));
7440
+ setEdit({ saving: true, error: null, conflict: null });
7441
+ const answered2 = await updateOnTheWinningVersion(client, recordId, patch, version, stored ?? {});
7442
+ setEdit({
7443
+ saving: false,
7444
+ error: answered2.ok ? null : answered2.error,
7445
+ conflict: answered2.ok ? null : (0, import_core7.asWriteConflict)(answered2.error, recordId)
7268
7446
  });
7447
+ const moved = answered2.ok ? answered2.data : null;
7269
7448
  if (moved !== null) {
7270
7449
  loaded.note(moved);
7450
+ setChanged({});
7271
7451
  host.notify?.success("Saved");
7272
7452
  existing.reload();
7273
7453
  onSaved?.(recordId);
@@ -7295,8 +7475,13 @@ function RecordForm({
7295
7475
  field,
7296
7476
  value: draft[field.key],
7297
7477
  document: existing.data?.document,
7298
- problems: touched ? predicted.filter((p) => p.field_key === field.key) : [],
7299
- onChange: (value) => setDraft((d) => ({ ...d, [field.key]: value }))
7478
+ problems: problemsShown(field.key),
7479
+ onChange: (value) => {
7480
+ setDraft((d) => ({ ...d, [field.key]: value }));
7481
+ setChanged((c) => c[field.key] ? c : { ...c, [field.key]: true });
7482
+ },
7483
+ onKeepEditing: () => keepEditing(field),
7484
+ onDiscard: () => discard(field)
7300
7485
  },
7301
7486
  field.id
7302
7487
  )) }),
@@ -7307,13 +7492,13 @@ function RecordForm({
7307
7492
  },
7308
7493
  p.message
7309
7494
  )) : null,
7310
- mutation.error ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7495
+ storeSaid && !storeRefusalIsOnAField ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7311
7496
  RefusalNotice,
7312
7497
  {
7313
- error: mutation.error,
7314
- ...mutation.conflict ? {
7498
+ error: storeSaid,
7499
+ ...said.conflict ? {
7315
7500
  actions: /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "mt-1 space-y-1", children: [
7316
- Object.entries(mutation.conflict.contested_fields).map(([key, theirs]) => {
7501
+ Object.entries(said.conflict.contested_fields).map(([key, theirs]) => {
7317
7502
  const column = (fields.data ?? []).find((f) => f.key === key);
7318
7503
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("p", { className: "text-[11px]", children: [
7319
7504
  column ? fieldName(column) : humanize(key),
@@ -7331,19 +7516,21 @@ function RecordForm({
7331
7516
  size: "sm",
7332
7517
  variant: "outline",
7333
7518
  onClick: () => {
7519
+ if (said.conflict) loaded.note(said.conflict.current_version);
7334
7520
  existing.reload();
7335
- mutation.clearConflict();
7521
+ said.clearConflict();
7522
+ setAnswered(storeSaid);
7336
7523
  },
7337
7524
  children: "Take theirs and start again"
7338
7525
  }
7339
7526
  )
7340
7527
  ] })
7341
- } : {}
7528
+ } : { onKeepEditing: () => keepEditing(), onDiscard: () => discard() }
7342
7529
  }
7343
7530
  ) : null,
7344
7531
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Separator, {}),
7345
7532
  /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "flex items-center gap-2", children: [
7346
- /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Button, { type: "submit", size: "sm", disabled: mutation.saving, children: mutation.saving ? "Saving\u2026" : recordId ? "Save" : "Create" }),
7533
+ /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Button, { type: "submit", size: "sm", disabled: said.saving, children: said.saving ? "Saving\u2026" : recordId ? "Save" : "Create" }),
7347
7534
  onCancel ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Button, { type: "button", size: "sm", variant: "ghost", onClick: onCancel, children: "Cancel" }) : null
7348
7535
  ] })
7349
7536
  ]
@@ -7355,17 +7542,20 @@ function FieldRow({
7355
7542
  value,
7356
7543
  document: document2,
7357
7544
  problems,
7358
- onChange
7545
+ onChange,
7546
+ onKeepEditing,
7547
+ onDiscard
7359
7548
  }) {
7360
7549
  const id = `field-${field.id}`;
7361
7550
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "flex min-w-0 flex-col gap-1", children: [
7362
7551
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(FieldLabel, { field, htmlFor: id, children: /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(ProvenanceBadge, { document: document2, fieldKey: field.key }) }),
7363
7552
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(FieldControl, { field, value, onChange, id }),
7364
- problems.map((problem) => /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7553
+ problems.map((problem, index) => /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7365
7554
  RefusalNotice,
7366
7555
  {
7367
7556
  error: { code: "refused_by_rule", message: problem.message },
7368
- className: "border-0 p-0"
7557
+ className: "border-0 p-0",
7558
+ ...index === 0 ? { onKeepEditing, onDiscard } : {}
7369
7559
  },
7370
7560
  problem.message
7371
7561
  ))
@@ -7658,6 +7848,9 @@ function viewFromRecord(row) {
7658
7848
  presentation: parseGridPresentation(data["presentation"])
7659
7849
  };
7660
7850
  }
7851
+ async function saveViewPatch(client, viewId, document2, expectedVersion) {
7852
+ return updateOnTheWinningVersion(client, viewId, document2, expectedVersion, "always");
7853
+ }
7661
7854
  function viewPatchDocument(patch) {
7662
7855
  const out = {};
7663
7856
  if (patch.name !== void 0) out["name"] = patch.name;
@@ -12053,6 +12246,21 @@ function whenItFires(subscription) {
12053
12246
  const every = subscription.cadence === "hourly" ? "an hourly summary" : subscription.cadence === "weekly" ? "a weekly summary" : "a daily summary";
12054
12247
  return subscription.schedule ? `${every}, ${subscription.schedule}` : every;
12055
12248
  }
12249
+ var PERIOD_MS = {
12250
+ hourly: 60 * 60 * 1e3,
12251
+ daily: 24 * 60 * 60 * 1e3,
12252
+ weekly: 7 * 24 * 60 * 60 * 1e3
12253
+ };
12254
+ function nextSummaryAt(subscription, now) {
12255
+ if (!subscription.next_digest_at) return null;
12256
+ const due = new Date(subscription.next_digest_at);
12257
+ if (Number.isNaN(due.getTime())) return null;
12258
+ if (due.getTime() > now.getTime()) return due;
12259
+ const period = PERIOD_MS[subscription.cadence];
12260
+ if (!period) return due;
12261
+ const behind = now.getTime() - due.getTime();
12262
+ return new Date(due.getTime() + (Math.floor(behind / period) + 1) * period);
12263
+ }
12056
12264
  var CHANNEL_WORDS2 = SUBSCRIPTION_CHANNEL_LABEL;
12057
12265
  var DIGEST_SUGGESTION = "Email me a summary of this every Monday at 8 in the morning.";
12058
12266
  function SubscriptionsPanel({ tableId, activeRuleId, className }) {
@@ -12180,7 +12388,7 @@ function SubscriptionsPanel({ tableId, activeRuleId, className }) {
12180
12388
  /* @__PURE__ */ (0, import_jsx_runtime41.jsxs)("div", { className: "mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground", children: [
12181
12389
  subscription.next_digest_at ? /* @__PURE__ */ (0, import_jsx_runtime41.jsxs)("span", { children: [
12182
12390
  "Next summary ",
12183
- new Date(subscription.next_digest_at).toLocaleString()
12391
+ nextSummaryAt(subscription, /* @__PURE__ */ new Date())?.toLocaleString()
12184
12392
  ] }) : subscription.cadence === "instant" ? null : subscription.muted ? /* @__PURE__ */ (0, import_jsx_runtime41.jsx)("span", { children: "No next summary while it is off." }) : null,
12185
12393
  subscription.last_sent_at ? /* @__PURE__ */ (0, import_jsx_runtime41.jsxs)("span", { children: [
12186
12394
  "Last told you ",
@@ -13876,14 +14084,14 @@ function NotifyRuleEditor({ tableId, seed, className }) {
13876
14084
  var import_react95 = require("react");
13877
14085
  var import_recharts = require("recharts");
13878
14086
  var import_records11 = require("@ai-matrx/records");
13879
- var import_core7 = require("@ai-matrx/records/core");
14087
+ var import_core8 = require("@ai-matrx/records/core");
13880
14088
  var import_design_system46 = require("@ai-matrx/design-system");
13881
14089
  var import_jsx_runtime49 = require("react/jsx-runtime");
13882
14090
  function pretty(n) {
13883
14091
  return Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(void 0, { maximumFractionDigits: 2 });
13884
14092
  }
13885
14093
  function asRefusal2(block) {
13886
- return (0, import_core7.mapPgError)(
14094
+ return (0, import_core8.mapPgError)(
13887
14095
  {
13888
14096
  message: block.refused ?? "",
13889
14097
  ...block.sqlstate ? { code: block.sqlstate } : {}
@@ -16330,13 +16538,30 @@ var LANE_EMPTY = {
16330
16538
  community: "Nothing shared beyond your organization. A table published by link or to the world lands here.",
16331
16539
  app: "The app has not needed a table of its own in this organization yet. Saving a view, writing a comment or building a form makes one, and it appears here rather than among your own data."
16332
16540
  };
16541
+ function keptByTheApp(table) {
16542
+ return table.kept_by_the_app === true;
16543
+ }
16333
16544
  function laneFor(table) {
16334
16545
  if (table.is_kernel) return "system";
16335
- if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX)) return "app";
16546
+ if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX) || keptByTheApp(table)) return "app";
16336
16547
  if (table.visibility === "public" || table.visibility === "link") return "community";
16337
16548
  if (table.visibility === "personal") return "mine";
16338
16549
  return "organization";
16339
16550
  }
16551
+ var VISIBILITY_LANES = ["mine", "organization", "community", "world"];
16552
+ var VISIBILITY_LANE_TITLE = {
16553
+ mine: "Mine",
16554
+ organization: "My organization",
16555
+ community: "Community",
16556
+ world: "World"
16557
+ };
16558
+ function visibilityLaneFor(table) {
16559
+ if (table.is_kernel) return null;
16560
+ if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX) || keptByTheApp(table)) return null;
16561
+ if (table.visibility === "personal") return "mine";
16562
+ if (table.visibility === "link" || table.visibility === "public") return "world";
16563
+ return "organization";
16564
+ }
16340
16565
  function TablesHome({ onOpenTable, className }) {
16341
16566
  const client = (0, import_react117.useRecordsClient)();
16342
16567
  const tables = (0, import_react117.useTables)();
@@ -16484,6 +16709,43 @@ var import_react118 = require("react");
16484
16709
  var import_react119 = require("@ai-matrx/records/react");
16485
16710
  var import_design_system57 = require("@ai-matrx/design-system");
16486
16711
  var import_jsx_runtime61 = require("react/jsx-runtime");
16712
+ var RAIL_TITLE = {
16713
+ settings: "Table settings",
16714
+ inbox: "Inbox",
16715
+ import: "Import",
16716
+ field: "Add a field",
16717
+ record: "Record",
16718
+ "new-record": "New record",
16719
+ forms: "Forms",
16720
+ bookings: "Bookings",
16721
+ checklists: "Checklists",
16722
+ notifications: "Notifications",
16723
+ portals: "Portals",
16724
+ "who-changed": "Who changed this"
16725
+ };
16726
+ function RailFrame({
16727
+ phone,
16728
+ title,
16729
+ onClose,
16730
+ children
16731
+ }) {
16732
+ if (phone) {
16733
+ return /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
16734
+ import_design_system57.BottomSheet,
16735
+ {
16736
+ open: true,
16737
+ onOpenChange: (open) => {
16738
+ if (!open) onClose();
16739
+ },
16740
+ title,
16741
+ size: "full",
16742
+ surface: "solid",
16743
+ children: /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("div", { "data-rail-sheet": "", className: "min-h-0 flex-1 overflow-y-auto px-3 pb-6", children })
16744
+ }
16745
+ );
16746
+ }
16747
+ return /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("aside", { "data-rail-column": "", className: "w-[26rem] shrink-0 overflow-y-auto rounded-md border p-3", children });
16748
+ }
16487
16749
  function filterInWords(filter, fields) {
16488
16750
  const nameOf = (key) => {
16489
16751
  const field = fields.find((f) => f.key === key);
@@ -16623,6 +16885,7 @@ function TablePage({
16623
16885
  const setRail = (next) => setSurface((now) => ({ ...now, rail: next }));
16624
16886
  const [openRecord, setOpenRecord] = (0, import_react118.useState)(opening.record);
16625
16887
  const viewVersion = useRecordVersion(view?.id ?? null);
16888
+ const phone = (0, import_design_system57.useIsMobile)();
16626
16889
  const shownLayout = layoutFromLink ?? view?.layout ?? "grid";
16627
16890
  const press = (pressed) => {
16628
16891
  const next = chooseSurface(surface, pressed);
@@ -16650,6 +16913,7 @@ function TablePage({
16650
16913
  setLayoutFromLink(isLayout ? named : null);
16651
16914
  setSurface((now) => ({ ...now, main: isLayout ? "records" : named }));
16652
16915
  }, [activeView]);
16916
+ const [viewSaveRefused, setViewSaveRefused] = (0, import_react118.useState)(null);
16653
16917
  const patchView = (0, import_react118.useCallback)(
16654
16918
  async (patch) => {
16655
16919
  const current = view;
@@ -16657,12 +16921,13 @@ function TablePage({
16657
16921
  setView({ ...current, ...patch });
16658
16922
  const document2 = viewPatchDocument(patch);
16659
16923
  if (Object.keys(document2).length === 0) return;
16660
- const written = await client.recordUpdate({
16661
- record_id: current.id,
16662
- patch: document2,
16663
- ...viewVersion.version === null ? {} : { expectedVersion: viewVersion.version }
16664
- });
16665
- if (written.ok) viewVersion.note(written.data);
16924
+ setViewSaveRefused(null);
16925
+ const written = await saveViewPatch(client, current.id, document2, viewVersion.version);
16926
+ if (written.ok) {
16927
+ viewVersion.note(written.data);
16928
+ return;
16929
+ }
16930
+ setViewSaveRefused({ error: written.error, patch, before: current });
16666
16931
  },
16667
16932
  [client, view, viewVersion]
16668
16933
  );
@@ -16813,6 +17078,27 @@ function TablePage({
16813
17078
  ),
16814
17079
  /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(ExportMenu, { tableId })
16815
17080
  ] }),
17081
+ viewSaveRefused ? /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
17082
+ RefusalNotice,
17083
+ {
17084
+ error: viewSaveRefused.error,
17085
+ actions: /* @__PURE__ */ (0, import_jsx_runtime61.jsxs)("span", { className: "flex flex-wrap gap-1 pt-0.5", "data-view-save-refused": "", children: [
17086
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(import_design_system57.Button, { size: "sm", variant: "outline", onClick: () => void patchView(viewSaveRefused.patch), children: "Try again" }),
17087
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
17088
+ import_design_system57.Button,
17089
+ {
17090
+ size: "sm",
17091
+ variant: "ghost",
17092
+ onClick: () => {
17093
+ setView(viewSaveRefused.before);
17094
+ setViewSaveRefused(null);
17095
+ },
17096
+ children: "Discard"
17097
+ }
17098
+ )
17099
+ ] })
17100
+ }
17101
+ ) : null,
16816
17102
  main === "dashboards" ? /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(DashboardCanvas, { tableId, activeDashboardId: activeDashboardId ?? null }) : main === "archived" ? (
16817
17103
  /* THE ROWS THAT LEFT. Restoring one puts it back in the grid, so the
16818
17104
  page returns to the records rather than leaving the person looking
@@ -16873,7 +17159,7 @@ function TablePage({
16873
17159
  view ? null : /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs text-muted-foreground", children: VIEW_NOT_SAVED_YET })
16874
17160
  ] })
16875
17161
  ] }),
16876
- rail === "none" ? null : /* @__PURE__ */ (0, import_jsx_runtime61.jsxs)("aside", { className: "w-[26rem] shrink-0 overflow-y-auto rounded-md border p-3", children: [
17162
+ rail === "none" ? null : /* @__PURE__ */ (0, import_jsx_runtime61.jsxs)(RailFrame, { phone, title: RAIL_TITLE[rail], onClose: () => setRail("none"), children: [
16877
17163
  rail === "settings" ? /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
16878
17164
  TableSettings,
16879
17165
  {