@ai-matrx/records-ui 0.82.0 → 0.83.0

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/CHANGELOG.md CHANGED
@@ -1,5 +1,27 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.83.0
4
+
5
+ **Four visibility lanes a filter can read, and the store's own choice lists stop sitting
6
+ among a person's tables.** (lane HUB-FIX, VERIFIER-15.)
7
+
8
+ * New `VISIBILITY_LANES` (`mine`, `organization`, `community`, `world`),
9
+ `VISIBILITY_LANE_TITLE` and `visibilityLaneFor(table)`: who can SEE a table, from its
10
+ own `visibility` — personal → Mine, internal → My organization, link → Community,
11
+ public → World. A kernel table, the app's bookkeeping and a store-kept choice list
12
+ answer `null`: nobody chose a visibility for them. `TABLE_LANES` (the table list's
13
+ SECTIONS: who MADE a table) is unchanged.
14
+ * `laneFor` puts a table whose record carries `kept_by_the_app: true` in the `app` lane.
15
+ On Rincon Plumbing Co that moves 27 per-field "Crew choices" / "Status choices" tables
16
+ out of My organization, where the hub above already counted them as the app's.
17
+ * New export `keptByTheApp(table)`.
18
+ * Test: `src/visibility-lanes.test.tsx` (4 cases).
19
+
20
+ **Consumer action (matrx-frontend):** the organization hub's lane strip
21
+ (`features/unified-data/hub/OrganizationHub.tsx`) renders `VISIBILITY_LANES` with
22
+ `VISIBILITY_LANE_TITLE`, and `features/unified-data/hub/capabilities.ts` decides an item's
23
+ lane with `visibilityLaneFor` (dropping its local `keptByTheApp`).
24
+
3
25
  ## 0.82.0
4
26
 
5
27
  **A link can open a table's Forms, Notifications, Portals or Share — and the thing inside
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, resent = false) => {
2387
2392
  const id = cellKey(rowId, key);
2388
2393
  setOptimistic((held) => ({ ...held, [id]: value }));
2389
2394
  setSaving((held) => ({ ...held, [id]: true }));
@@ -2426,16 +2431,25 @@ 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 && !Object.prototype.hasOwnProperty.call(conflict.contested_fields, key)) {
2439
+ await writeCellNow(rowId, key, value, 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)(
@@ -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);
@@ -2561,6 +2602,8 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
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",
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",
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",
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" })
@@ -7237,6 +7321,8 @@ function RecordForm({
7237
7321
  const mutation = (0, import_react42.useRecordMutation)();
7238
7322
  const [draft, setDraft] = (0, import_react41.useState)({});
7239
7323
  const [touched, setTouched] = (0, import_react41.useState)(false);
7324
+ const [changed, setChanged] = (0, import_react41.useState)({});
7325
+ const [answered, setAnswered] = (0, import_react41.useState)(null);
7240
7326
  const loaded = useRecordVersion(recordId ?? null);
7241
7327
  const loadedVersion = loaded.version;
7242
7328
  (0, import_react41.useEffect)(() => {
@@ -7257,8 +7343,38 @@ function RecordForm({
7257
7343
  );
7258
7344
  if (fields.error) return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(RefusalNotice, { error: fields.error, className });
7259
7345
  if (existing.error) return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(RefusalNotice, { error: existing.error, className });
7346
+ const storeSaid = mutation.error && mutation.error !== answered ? mutation.error : null;
7347
+ const fieldProblems = predicted.filter((p) => p.field_key);
7348
+ const problemsShown = (key) => touched || changed[key] ? fieldProblems.filter((p) => p.field_key === key) : [];
7349
+ const storeRefusalIsOnAField = storeSaid !== null && !mutation.conflict && storeSaid.code === "refused_by_rule" && (fields.data ?? []).some((f) => problemsShown(f.key).length > 0);
7350
+ const stored = existing.data?.document;
7351
+ const focusField = (field) => {
7352
+ if (!field) return;
7353
+ globalThis.document?.getElementById(`field-${field.id}`)?.focus();
7354
+ };
7355
+ const keepEditing = (field) => {
7356
+ if (storeSaid) setAnswered(storeSaid);
7357
+ const first = field ?? (fields.data ?? []).find((f) => problemsShown(f.key).length > 0) ?? (fields.data ?? [])[0];
7358
+ focusField(first);
7359
+ };
7360
+ const discard = (field) => {
7361
+ if (storeSaid) setAnswered(storeSaid);
7362
+ if (field) {
7363
+ setDraft((d) => ({ ...d, [field.key]: stored ? stored[field.key] : void 0 }));
7364
+ setChanged((c) => {
7365
+ const next = { ...c };
7366
+ delete next[field.key];
7367
+ return next;
7368
+ });
7369
+ return;
7370
+ }
7371
+ setDraft(stored ? { ...stored } : {});
7372
+ setChanged({});
7373
+ setTouched(false);
7374
+ };
7260
7375
  const save = async () => {
7261
7376
  setTouched(true);
7377
+ setAnswered(null);
7262
7378
  if (recordId) {
7263
7379
  const version = loadedVersion;
7264
7380
  const moved = await mutation.update({
@@ -7295,8 +7411,13 @@ function RecordForm({
7295
7411
  field,
7296
7412
  value: draft[field.key],
7297
7413
  document: existing.data?.document,
7298
- problems: touched ? predicted.filter((p) => p.field_key === field.key) : [],
7299
- onChange: (value) => setDraft((d) => ({ ...d, [field.key]: value }))
7414
+ problems: problemsShown(field.key),
7415
+ onChange: (value) => {
7416
+ setDraft((d) => ({ ...d, [field.key]: value }));
7417
+ setChanged((c) => c[field.key] ? c : { ...c, [field.key]: true });
7418
+ },
7419
+ onKeepEditing: () => keepEditing(field),
7420
+ onDiscard: () => discard(field)
7300
7421
  },
7301
7422
  field.id
7302
7423
  )) }),
@@ -7307,10 +7428,10 @@ function RecordForm({
7307
7428
  },
7308
7429
  p.message
7309
7430
  )) : null,
7310
- mutation.error ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7431
+ storeSaid && !storeRefusalIsOnAField ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7311
7432
  RefusalNotice,
7312
7433
  {
7313
- error: mutation.error,
7434
+ error: storeSaid,
7314
7435
  ...mutation.conflict ? {
7315
7436
  actions: /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "mt-1 space-y-1", children: [
7316
7437
  Object.entries(mutation.conflict.contested_fields).map(([key, theirs]) => {
@@ -7331,14 +7452,16 @@ function RecordForm({
7331
7452
  size: "sm",
7332
7453
  variant: "outline",
7333
7454
  onClick: () => {
7455
+ if (mutation.conflict) loaded.note(mutation.conflict.current_version);
7334
7456
  existing.reload();
7335
7457
  mutation.clearConflict();
7458
+ setAnswered(storeSaid);
7336
7459
  },
7337
7460
  children: "Take theirs and start again"
7338
7461
  }
7339
7462
  )
7340
7463
  ] })
7341
- } : {}
7464
+ } : { onKeepEditing: () => keepEditing(), onDiscard: () => discard() }
7342
7465
  }
7343
7466
  ) : null,
7344
7467
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Separator, {}),
@@ -7355,17 +7478,20 @@ function FieldRow({
7355
7478
  value,
7356
7479
  document: document2,
7357
7480
  problems,
7358
- onChange
7481
+ onChange,
7482
+ onKeepEditing,
7483
+ onDiscard
7359
7484
  }) {
7360
7485
  const id = `field-${field.id}`;
7361
7486
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "flex min-w-0 flex-col gap-1", children: [
7362
7487
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(FieldLabel, { field, htmlFor: id, children: /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(ProvenanceBadge, { document: document2, fieldKey: field.key }) }),
7363
7488
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(FieldControl, { field, value, onChange, id }),
7364
- problems.map((problem) => /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7489
+ problems.map((problem, index) => /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(
7365
7490
  RefusalNotice,
7366
7491
  {
7367
7492
  error: { code: "refused_by_rule", message: problem.message },
7368
- className: "border-0 p-0"
7493
+ className: "border-0 p-0",
7494
+ ...index === 0 ? { onKeepEditing, onDiscard } : {}
7369
7495
  },
7370
7496
  problem.message
7371
7497
  ))
@@ -7535,6 +7661,7 @@ function computedInPlainWords(row, field) {
7535
7661
  }
7536
7662
 
7537
7663
  // src/views.ts
7664
+ var import_core7 = require("@ai-matrx/records/core");
7538
7665
  var VIEW_LAYOUTS = ["grid", "kanban", "calendar", "gallery"];
7539
7666
  var LAYOUT_LABEL = {
7540
7667
  grid: "Grid",
@@ -7658,6 +7785,18 @@ function viewFromRecord(row) {
7658
7785
  presentation: parseGridPresentation(data["presentation"])
7659
7786
  };
7660
7787
  }
7788
+ async function saveViewPatch(client, viewId, document2, expectedVersion) {
7789
+ const send = (expected) => client.recordUpdate({
7790
+ record_id: viewId,
7791
+ patch: document2,
7792
+ ...expected === null ? {} : { expectedVersion: expected }
7793
+ });
7794
+ const first = await send(expectedVersion);
7795
+ if (first.ok) return first;
7796
+ const conflict = (0, import_core7.asWriteConflict)(first.error, viewId);
7797
+ if (!conflict) return first;
7798
+ return send(conflict.current_version);
7799
+ }
7661
7800
  function viewPatchDocument(patch) {
7662
7801
  const out = {};
7663
7802
  if (patch.name !== void 0) out["name"] = patch.name;
@@ -12053,6 +12192,21 @@ function whenItFires(subscription) {
12053
12192
  const every = subscription.cadence === "hourly" ? "an hourly summary" : subscription.cadence === "weekly" ? "a weekly summary" : "a daily summary";
12054
12193
  return subscription.schedule ? `${every}, ${subscription.schedule}` : every;
12055
12194
  }
12195
+ var PERIOD_MS = {
12196
+ hourly: 60 * 60 * 1e3,
12197
+ daily: 24 * 60 * 60 * 1e3,
12198
+ weekly: 7 * 24 * 60 * 60 * 1e3
12199
+ };
12200
+ function nextSummaryAt(subscription, now) {
12201
+ if (!subscription.next_digest_at) return null;
12202
+ const due = new Date(subscription.next_digest_at);
12203
+ if (Number.isNaN(due.getTime())) return null;
12204
+ if (due.getTime() > now.getTime()) return due;
12205
+ const period = PERIOD_MS[subscription.cadence];
12206
+ if (!period) return due;
12207
+ const behind = now.getTime() - due.getTime();
12208
+ return new Date(due.getTime() + (Math.floor(behind / period) + 1) * period);
12209
+ }
12056
12210
  var CHANNEL_WORDS2 = SUBSCRIPTION_CHANNEL_LABEL;
12057
12211
  var DIGEST_SUGGESTION = "Email me a summary of this every Monday at 8 in the morning.";
12058
12212
  function SubscriptionsPanel({ tableId, activeRuleId, className }) {
@@ -12180,7 +12334,7 @@ function SubscriptionsPanel({ tableId, activeRuleId, className }) {
12180
12334
  /* @__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
12335
  subscription.next_digest_at ? /* @__PURE__ */ (0, import_jsx_runtime41.jsxs)("span", { children: [
12182
12336
  "Next summary ",
12183
- new Date(subscription.next_digest_at).toLocaleString()
12337
+ nextSummaryAt(subscription, /* @__PURE__ */ new Date())?.toLocaleString()
12184
12338
  ] }) : subscription.cadence === "instant" ? null : subscription.muted ? /* @__PURE__ */ (0, import_jsx_runtime41.jsx)("span", { children: "No next summary while it is off." }) : null,
12185
12339
  subscription.last_sent_at ? /* @__PURE__ */ (0, import_jsx_runtime41.jsxs)("span", { children: [
12186
12340
  "Last told you ",
@@ -13876,14 +14030,14 @@ function NotifyRuleEditor({ tableId, seed, className }) {
13876
14030
  var import_react95 = require("react");
13877
14031
  var import_recharts = require("recharts");
13878
14032
  var import_records11 = require("@ai-matrx/records");
13879
- var import_core7 = require("@ai-matrx/records/core");
14033
+ var import_core8 = require("@ai-matrx/records/core");
13880
14034
  var import_design_system46 = require("@ai-matrx/design-system");
13881
14035
  var import_jsx_runtime49 = require("react/jsx-runtime");
13882
14036
  function pretty(n) {
13883
14037
  return Number.isInteger(n) ? n.toLocaleString() : n.toLocaleString(void 0, { maximumFractionDigits: 2 });
13884
14038
  }
13885
14039
  function asRefusal2(block) {
13886
- return (0, import_core7.mapPgError)(
14040
+ return (0, import_core8.mapPgError)(
13887
14041
  {
13888
14042
  message: block.refused ?? "",
13889
14043
  ...block.sqlstate ? { code: block.sqlstate } : {}
@@ -16330,13 +16484,31 @@ var LANE_EMPTY = {
16330
16484
  community: "Nothing shared beyond your organization. A table published by link or to the world lands here.",
16331
16485
  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
16486
  };
16487
+ function keptByTheApp(table) {
16488
+ return table.kept_by_the_app === true;
16489
+ }
16333
16490
  function laneFor(table) {
16334
16491
  if (table.is_kernel) return "system";
16335
- if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX)) return "app";
16492
+ if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX) || keptByTheApp(table)) return "app";
16336
16493
  if (table.visibility === "public" || table.visibility === "link") return "community";
16337
16494
  if (table.visibility === "personal") return "mine";
16338
16495
  return "organization";
16339
16496
  }
16497
+ var VISIBILITY_LANES = ["mine", "organization", "community", "world"];
16498
+ var VISIBILITY_LANE_TITLE = {
16499
+ mine: "Mine",
16500
+ organization: "My organization",
16501
+ community: "Community",
16502
+ world: "World"
16503
+ };
16504
+ function visibilityLaneFor(table) {
16505
+ if (table.is_kernel) return null;
16506
+ if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX) || keptByTheApp(table)) return null;
16507
+ if (table.visibility === "personal") return "mine";
16508
+ if (table.visibility === "link") return "community";
16509
+ if (table.visibility === "public") return "world";
16510
+ return "organization";
16511
+ }
16340
16512
  function TablesHome({ onOpenTable, className }) {
16341
16513
  const client = (0, import_react117.useRecordsClient)();
16342
16514
  const tables = (0, import_react117.useTables)();
@@ -16484,6 +16656,43 @@ var import_react118 = require("react");
16484
16656
  var import_react119 = require("@ai-matrx/records/react");
16485
16657
  var import_design_system57 = require("@ai-matrx/design-system");
16486
16658
  var import_jsx_runtime61 = require("react/jsx-runtime");
16659
+ var RAIL_TITLE = {
16660
+ settings: "Table settings",
16661
+ inbox: "Inbox",
16662
+ import: "Import",
16663
+ field: "Add a field",
16664
+ record: "Record",
16665
+ "new-record": "New record",
16666
+ forms: "Forms",
16667
+ bookings: "Bookings",
16668
+ checklists: "Checklists",
16669
+ notifications: "Notifications",
16670
+ portals: "Portals",
16671
+ "who-changed": "Who changed this"
16672
+ };
16673
+ function RailFrame({
16674
+ phone,
16675
+ title,
16676
+ onClose,
16677
+ children
16678
+ }) {
16679
+ if (phone) {
16680
+ return /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
16681
+ import_design_system57.BottomSheet,
16682
+ {
16683
+ open: true,
16684
+ onOpenChange: (open) => {
16685
+ if (!open) onClose();
16686
+ },
16687
+ title,
16688
+ size: "full",
16689
+ surface: "solid",
16690
+ 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 })
16691
+ }
16692
+ );
16693
+ }
16694
+ 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 });
16695
+ }
16487
16696
  function filterInWords(filter, fields) {
16488
16697
  const nameOf = (key) => {
16489
16698
  const field = fields.find((f) => f.key === key);
@@ -16623,6 +16832,7 @@ function TablePage({
16623
16832
  const setRail = (next) => setSurface((now) => ({ ...now, rail: next }));
16624
16833
  const [openRecord, setOpenRecord] = (0, import_react118.useState)(opening.record);
16625
16834
  const viewVersion = useRecordVersion(view?.id ?? null);
16835
+ const phone = (0, import_design_system57.useIsMobile)();
16626
16836
  const shownLayout = layoutFromLink ?? view?.layout ?? "grid";
16627
16837
  const press = (pressed) => {
16628
16838
  const next = chooseSurface(surface, pressed);
@@ -16650,6 +16860,7 @@ function TablePage({
16650
16860
  setLayoutFromLink(isLayout ? named : null);
16651
16861
  setSurface((now) => ({ ...now, main: isLayout ? "records" : named }));
16652
16862
  }, [activeView]);
16863
+ const [viewSaveRefused, setViewSaveRefused] = (0, import_react118.useState)(null);
16653
16864
  const patchView = (0, import_react118.useCallback)(
16654
16865
  async (patch) => {
16655
16866
  const current = view;
@@ -16657,12 +16868,13 @@ function TablePage({
16657
16868
  setView({ ...current, ...patch });
16658
16869
  const document2 = viewPatchDocument(patch);
16659
16870
  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);
16871
+ setViewSaveRefused(null);
16872
+ const written = await saveViewPatch(client, current.id, document2, viewVersion.version);
16873
+ if (written.ok) {
16874
+ viewVersion.note(written.data);
16875
+ return;
16876
+ }
16877
+ setViewSaveRefused({ error: written.error, patch, before: current });
16666
16878
  },
16667
16879
  [client, view, viewVersion]
16668
16880
  );
@@ -16813,6 +17025,27 @@ function TablePage({
16813
17025
  ),
16814
17026
  /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(ExportMenu, { tableId })
16815
17027
  ] }),
17028
+ viewSaveRefused ? /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
17029
+ RefusalNotice,
17030
+ {
17031
+ error: viewSaveRefused.error,
17032
+ actions: /* @__PURE__ */ (0, import_jsx_runtime61.jsxs)("span", { className: "flex flex-wrap gap-1 pt-0.5", "data-view-save-refused": "", children: [
17033
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(import_design_system57.Button, { size: "sm", variant: "outline", onClick: () => void patchView(viewSaveRefused.patch), children: "Try again" }),
17034
+ /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
17035
+ import_design_system57.Button,
17036
+ {
17037
+ size: "sm",
17038
+ variant: "ghost",
17039
+ onClick: () => {
17040
+ setView(viewSaveRefused.before);
17041
+ setViewSaveRefused(null);
17042
+ },
17043
+ children: "Discard"
17044
+ }
17045
+ )
17046
+ ] })
17047
+ }
17048
+ ) : null,
16816
17049
  main === "dashboards" ? /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(DashboardCanvas, { tableId, activeDashboardId: activeDashboardId ?? null }) : main === "archived" ? (
16817
17050
  /* THE ROWS THAT LEFT. Restoring one puts it back in the grid, so the
16818
17051
  page returns to the records rather than leaving the person looking
@@ -16873,7 +17106,7 @@ function TablePage({
16873
17106
  view ? null : /* @__PURE__ */ (0, import_jsx_runtime61.jsx)("p", { className: "text-xs text-muted-foreground", children: VIEW_NOT_SAVED_YET })
16874
17107
  ] })
16875
17108
  ] }),
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: [
17109
+ rail === "none" ? null : /* @__PURE__ */ (0, import_jsx_runtime61.jsxs)(RailFrame, { phone, title: RAIL_TITLE[rail], onClose: () => setRail("none"), children: [
16877
17110
  rail === "settings" ? /* @__PURE__ */ (0, import_jsx_runtime61.jsx)(
16878
17111
  TableSettings,
16879
17112
  {