@ai-matrx/records-ui 0.83.0 → 0.84.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,66 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.84.0
4
+
5
+ **A refusal is said once, with the doors that fit it, and a change a person made is kept or
6
+ said — never dropped.** (lane RECORDS-UI-FIX, VERIFIER-15 H2, H3, M3, M4, M6, M7.)
7
+
8
+ * **The grid's cell refusal is the one notice.** A rule refusal (`-3` in a column "at least
9
+ 0") used to draw a small red line with *Try again* (which re-sent the refused value) and
10
+ *Keep theirs* (conflict wording when nobody else wrote). It is now `RefusalNotice` with
11
+ **Keep editing** — the cell reopens holding `-3`, the reason still beside it — and
12
+ **Discard**. *Try again* / *Keep theirs* appear only on a real version conflict, beside
13
+ the other person's value. A tick box's refusal carries Discard.
14
+ * **The record form says a refusal once, with Keep editing / Discard, and before Save.** The
15
+ mirror's per-column notice is the store's rule, so the store's copy under the form is no
16
+ longer drawn beside it; the mirror speaks the moment a changed column would be refused.
17
+ What was typed survives the (slower) version read landing late. An edit sends only the
18
+ columns the person changed, and a Save with nothing changed writes nothing.
19
+ * **A board's layout or grouping change is kept, or said.** Every view save carried a version
20
+ read from the view's history, which can stop short of the record, so every Kanban / Group
21
+ by press was refused `stale_write` and dropped in silence. New `saveViewPatch` (the one
22
+ view-save door) writes on the version the refusal names; a save still refused is said
23
+ through `RefusalNotice` with Try again / Discard.
24
+ * **"Someone else wrote first" only when somebody did.** The store's `contested_fields`
25
+ carries each patched column's current value; when every one is still what the screen
26
+ started from, the write goes once more on the winning version (the grid cell session, the
27
+ record form and saved views apply the same rule — internal `updateOnTheWinningVersion`).
28
+ * **The next digest summary is never in the past.** New export `nextSummaryAt(subscription,
29
+ now)` carries a passed due time forward by whole periods to the first slot after now,
30
+ keeping the store's alignment (tested at a fixed clock).
31
+ * **Table Settings never cuts a field name to two letters** — the name wraps with a readable
32
+ minimum and the row wraps its controls first.
33
+ * **On a phone the table's rail is a sheet over the table** — below the design system's
34
+ breakpoint (`useIsMobile`, 768px) it renders through the canonical `BottomSheet`, the way
35
+ `Dialog` and `CommandDialog` already do; dragging it down closes the rail.
36
+ * Tests (each shown failing on the prior bytes): `src/a-refused-cell-keeps-what-you-typed`,
37
+ `src/a-form-refusal-is-said-once-with-its-doors`, `src/a-board-layout-change-is-kept`,
38
+ `src/the-next-summary-is-never-in-the-past`, `src/a-rail-on-a-phone-is-a-sheet`, and a
39
+ new clause in `src/TableSettings.hooks.test`.
40
+
41
+ **Store-side, reported to the chair (not in this package):** `custom.record_update` of an
42
+ unchanged value moves `version` and writes no history row (reproduced on the dev clone), and
43
+ the digest runner computes the next slot from the last summary SENT, so an empty window
44
+ freezes it.
45
+
46
+ **Consumer action (matrx-frontend):** none required — install picks it up. A host that
47
+ shows a digest's next time outside `SubscriptionsPanel` should use `nextSummaryAt`.
48
+
49
+ ## 0.83.1
50
+
51
+ **A table shared by link is in the World lane, not Community.** (lane HUB-FIX, chair ruling
52
+ 2026-09-23.)
53
+
54
+ * `visibilityLaneFor` maps `link` → `world` (was `community`). A link anyone can open without
55
+ signing in reaches the world; Community is for things any SIGNED-IN account can reach, and
56
+ the store has no signed-in-only link yet, so no stored visibility lands in Community today.
57
+ `VISIBILITY_LANES` still carries all four — the chip stays.
58
+ * Test: `src/visibility-lanes.test.tsx` updated (link → world) plus a case pinning that no
59
+ stored visibility maps to Community yet — 5 cases.
60
+
61
+ **Consumer action:** none. The hub reads `visibilityLaneFor`, so link-shared tables move from
62
+ the Community chip to the World chip on install.
63
+
3
64
  ## 0.83.0
4
65
 
5
66
  **Four visibility lanes a filter can read, and the store's own choice lists stop sitting
package/dist/index.cjs CHANGED
@@ -256,6 +256,7 @@ __export(src_exports, {
256
256
  mayNotRestoreLine: () => mayNotRestoreLine,
257
257
  memberName: () => memberName,
258
258
  nextInWords: () => nextInWords,
259
+ nextSummaryAt: () => nextSummaryAt,
259
260
  openingRail: () => openingRail,
260
261
  openingView: () => openingView,
261
262
  pageViewFromParam: () => pageViewFromParam,
@@ -2388,7 +2389,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2388
2389
  startedFrom.current = null;
2389
2390
  }, []);
2390
2391
  const writeCellNow = (0, import_react15.useCallback)(
2391
- async (rowId, key, value, resent = false) => {
2392
+ async (rowId, key, value, base, resent = false) => {
2392
2393
  const id = cellKey(rowId, key);
2393
2394
  setOptimistic((held) => ({ ...held, [id]: value }));
2394
2395
  setSaving((held) => ({ ...held, [id]: true }));
@@ -2435,8 +2436,8 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2435
2436
  if (written.error.code === "stale_write") {
2436
2437
  if (conflict) rememberVersion(versions.current, rowId, conflict.current_version);
2437
2438
  else versions.current.delete(rowId);
2438
- if (conflict && !resent && !Object.prototype.hasOwnProperty.call(conflict.contested_fields, key)) {
2439
- await writeCellNow(rowId, key, value, true);
2439
+ if (conflict && !resent && sameValue(conflict.contested_fields[key], base)) {
2440
+ await writeCellNow(rowId, key, value, base, true);
2440
2441
  return;
2441
2442
  }
2442
2443
  }
@@ -2453,9 +2454,9 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2453
2454
  [client, reload]
2454
2455
  );
2455
2456
  const writeCell = (0, import_react15.useCallback)(
2456
- (rowId, key, value) => {
2457
+ (rowId, key, value, base) => {
2457
2458
  const behind = queues.current.get(rowId) ?? Promise.resolve();
2458
- const next = behind.then(() => writeCellNow(rowId, key, value));
2459
+ const next = behind.then(() => writeCellNow(rowId, key, value, base));
2459
2460
  queues.current.set(
2460
2461
  rowId,
2461
2462
  next.then(
@@ -2473,7 +2474,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2473
2474
  const open = editing;
2474
2475
  if (!open) return;
2475
2476
  const value = typed.current;
2476
- if (!sameValue(startedFrom.current, value)) void writeCell(open.rowId, open.key, value);
2477
+ if (!sameValue(startedFrom.current, value)) void writeCell(open.rowId, open.key, value, startedFrom.current);
2477
2478
  if (!move) {
2478
2479
  setEditing(null);
2479
2480
  setDraft(null);
@@ -2502,7 +2503,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2502
2503
  (rowId, key) => {
2503
2504
  const held = refusals[cellKey(rowId, key)];
2504
2505
  if (!held) return;
2505
- void writeCell(rowId, key, held.attempted);
2506
+ void writeCell(rowId, key, held.attempted, held.conflict?.contested_fields[key]);
2506
2507
  },
2507
2508
  [refusals, writeCell]
2508
2509
  );
@@ -2598,7 +2599,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite, mayWriteRow }
2598
2599
  commit,
2599
2600
  setCell: (rowId, key, value) => {
2600
2601
  if (!writable(rowId)) return;
2601
- void writeCell(rowId, key, value);
2602
+ void writeCell(rowId, key, value, shownValue(rowId, key));
2602
2603
  },
2603
2604
  retry,
2604
2605
  keepTheirs,
@@ -2683,7 +2684,7 @@ function GridCell({
2683
2684
  RefusalNotice,
2684
2685
  {
2685
2686
  error: refusal2.error,
2686
- className: "py-1.5",
2687
+ className: "py-1.5 text-left",
2687
2688
  onDiscard: () => editing.discard(row.id, field.key)
2688
2689
  }
2689
2690
  ) : null
@@ -2797,7 +2798,7 @@ function CellRefusalNotice({
2797
2798
  RefusalNotice,
2798
2799
  {
2799
2800
  error: refusal2.error,
2800
- className: "py-1.5",
2801
+ className: "py-1.5 text-left",
2801
2802
  actions: /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "flex flex-col gap-1", "data-refusal-conflict": "", children: [
2802
2803
  /* @__PURE__ */ (0, import_jsx_runtime10.jsxs)("span", { className: "text-[11px]", children: [
2803
2804
  "Theirs: ",
@@ -2815,7 +2816,7 @@ function CellRefusalNotice({
2815
2816
  RefusalNotice,
2816
2817
  {
2817
2818
  error: refusal2.error,
2818
- className: "py-1.5",
2819
+ className: "py-1.5 text-left",
2819
2820
  ...typed ? { onKeepEditing: () => editing?.keepEditing(row.id, field.key) } : {},
2820
2821
  onDiscard: () => editing?.discard(row.id, field.key)
2821
2822
  }
@@ -7131,7 +7132,39 @@ var import_design_system22 = require("@ai-matrx/design-system");
7131
7132
  // src/RecordForm.tsx
7132
7133
  var import_react41 = require("react");
7133
7134
  var import_react42 = require("@ai-matrx/records/react");
7135
+ var import_core7 = require("@ai-matrx/records/core");
7136
+
7137
+ // src/writeOnTheWinningVersion.ts
7134
7138
  var import_core6 = require("@ai-matrx/records/core");
7139
+ function sameStoredValue(left, right) {
7140
+ if (left === right) return true;
7141
+ const empty = (v) => v === null || v === void 0 || v === "";
7142
+ if (empty(left) || empty(right)) return empty(left) && empty(right);
7143
+ if (typeof left === "object" || typeof right === "object") {
7144
+ try {
7145
+ return JSON.stringify(left) === JSON.stringify(right);
7146
+ } catch {
7147
+ return false;
7148
+ }
7149
+ }
7150
+ return false;
7151
+ }
7152
+ async function updateOnTheWinningVersion(client, recordId, patch, expectedVersion, base) {
7153
+ const send = (expected) => client.recordUpdate({
7154
+ record_id: recordId,
7155
+ patch,
7156
+ ...expected === null ? {} : { expectedVersion: expected }
7157
+ });
7158
+ const first = await send(expectedVersion);
7159
+ if (first.ok) return first;
7160
+ const conflict = (0, import_core6.asWriteConflict)(first.error, recordId);
7161
+ if (!conflict) return first;
7162
+ const untouched = base === "always" || Object.keys(patch).every((key) => sameStoredValue(conflict.contested_fields[key], base[key]));
7163
+ if (!untouched) return first;
7164
+ return send(conflict.current_version);
7165
+ }
7166
+
7167
+ // src/RecordForm.tsx
7135
7168
  var import_design_system20 = require("@ai-matrx/design-system");
7136
7169
 
7137
7170
  // src/systemTable.ts
@@ -7319,15 +7352,38 @@ function RecordForm({
7319
7352
  const fields = (0, import_react42.useFields)(tableId, recordType);
7320
7353
  const existing = (0, import_react42.useRecord)(recordId ?? null);
7321
7354
  const mutation = (0, import_react42.useRecordMutation)();
7355
+ const client = (0, import_react42.useRecordsClient)();
7356
+ const [edit, setEdit] = (0, import_react41.useState)({
7357
+ saving: false,
7358
+ error: null,
7359
+ conflict: null
7360
+ });
7361
+ const said = {
7362
+ saving: mutation.saving || edit.saving,
7363
+ error: recordId ? edit.error : mutation.error,
7364
+ conflict: recordId ? edit.conflict : mutation.conflict,
7365
+ clearConflict: () => {
7366
+ mutation.clearConflict();
7367
+ setEdit((e) => ({ ...e, conflict: null }));
7368
+ }
7369
+ };
7322
7370
  const [draft, setDraft] = (0, import_react41.useState)({});
7323
7371
  const [touched, setTouched] = (0, import_react41.useState)(false);
7324
7372
  const [changed, setChanged] = (0, import_react41.useState)({});
7325
7373
  const [answered, setAnswered] = (0, import_react41.useState)(null);
7326
7374
  const loaded = useRecordVersion(recordId ?? null);
7327
7375
  const loadedVersion = loaded.version;
7376
+ const changedRef = (0, import_react41.useRef)({});
7377
+ changedRef.current = changed;
7328
7378
  (0, import_react41.useEffect)(() => {
7329
7379
  const document3 = existing.data?.document;
7330
- if (document3) setDraft({ ...document3 });
7380
+ if (!document3) return;
7381
+ const mine = changedRef.current;
7382
+ setDraft((held) => {
7383
+ const next = { ...document3 };
7384
+ for (const key of Object.keys(mine)) next[key] = held[key];
7385
+ return next;
7386
+ });
7331
7387
  }, [existing.data?.record_id, loadedVersion]);
7332
7388
  const document2 = (0, import_react41.useMemo)(() => {
7333
7389
  const out = {};
@@ -7338,15 +7394,15 @@ function RecordForm({
7338
7394
  return out;
7339
7395
  }, [draft]);
7340
7396
  const predicted = (0, import_react41.useMemo)(
7341
- () => fields.data ? (0, import_core6.predictWriteRefusals)({ fields: fields.data, document: document2, ...recordType ? { recordType } : {} }) : [],
7397
+ () => fields.data ? (0, import_core7.predictWriteRefusals)({ fields: fields.data, document: document2, ...recordType ? { recordType } : {} }) : [],
7342
7398
  [fields.data, document2, recordType]
7343
7399
  );
7344
7400
  if (fields.error) return /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(RefusalNotice, { error: fields.error, className });
7345
7401
  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;
7402
+ const storeSaid = said.error && said.error !== answered ? said.error : null;
7347
7403
  const fieldProblems = predicted.filter((p) => p.field_key);
7348
7404
  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);
7405
+ const storeRefusalIsOnAField = storeSaid !== null && !said.conflict && storeSaid.code === "refused_by_rule" && (fields.data ?? []).some((f) => problemsShown(f.key).length > 0);
7350
7406
  const stored = existing.data?.document;
7351
7407
  const focusField = (field) => {
7352
7408
  if (!field) return;
@@ -7377,13 +7433,22 @@ function RecordForm({
7377
7433
  setAnswered(null);
7378
7434
  if (recordId) {
7379
7435
  const version = loadedVersion;
7380
- const moved = await mutation.update({
7381
- record_id: recordId,
7382
- patch: document2,
7383
- ...version === null ? {} : { expectedVersion: version }
7436
+ if (Object.keys(changed).length === 0) {
7437
+ onSaved?.(recordId);
7438
+ return;
7439
+ }
7440
+ const patch = Object.fromEntries(Object.entries(document2).filter(([key]) => changed[key]));
7441
+ setEdit({ saving: true, error: null, conflict: null });
7442
+ const answered2 = await updateOnTheWinningVersion(client, recordId, patch, version, stored ?? {});
7443
+ setEdit({
7444
+ saving: false,
7445
+ error: answered2.ok ? null : answered2.error,
7446
+ conflict: answered2.ok ? null : (0, import_core7.asWriteConflict)(answered2.error, recordId)
7384
7447
  });
7448
+ const moved = answered2.ok ? answered2.data : null;
7385
7449
  if (moved !== null) {
7386
7450
  loaded.note(moved);
7451
+ setChanged({});
7387
7452
  host.notify?.success("Saved");
7388
7453
  existing.reload();
7389
7454
  onSaved?.(recordId);
@@ -7432,9 +7497,9 @@ function RecordForm({
7432
7497
  RefusalNotice,
7433
7498
  {
7434
7499
  error: storeSaid,
7435
- ...mutation.conflict ? {
7500
+ ...said.conflict ? {
7436
7501
  actions: /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "mt-1 space-y-1", children: [
7437
- Object.entries(mutation.conflict.contested_fields).map(([key, theirs]) => {
7502
+ Object.entries(said.conflict.contested_fields).map(([key, theirs]) => {
7438
7503
  const column = (fields.data ?? []).find((f) => f.key === key);
7439
7504
  return /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("p", { className: "text-[11px]", children: [
7440
7505
  column ? fieldName(column) : humanize(key),
@@ -7452,9 +7517,9 @@ function RecordForm({
7452
7517
  size: "sm",
7453
7518
  variant: "outline",
7454
7519
  onClick: () => {
7455
- if (mutation.conflict) loaded.note(mutation.conflict.current_version);
7520
+ if (said.conflict) loaded.note(said.conflict.current_version);
7456
7521
  existing.reload();
7457
- mutation.clearConflict();
7522
+ said.clearConflict();
7458
7523
  setAnswered(storeSaid);
7459
7524
  },
7460
7525
  children: "Take theirs and start again"
@@ -7466,7 +7531,7 @@ function RecordForm({
7466
7531
  ) : null,
7467
7532
  /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Separator, {}),
7468
7533
  /* @__PURE__ */ (0, import_jsx_runtime23.jsxs)("div", { className: "flex items-center gap-2", children: [
7469
- /* @__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" }),
7534
+ /* @__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" }),
7470
7535
  onCancel ? /* @__PURE__ */ (0, import_jsx_runtime23.jsx)(import_design_system20.Button, { type: "button", size: "sm", variant: "ghost", onClick: onCancel, children: "Cancel" }) : null
7471
7536
  ] })
7472
7537
  ]
@@ -7661,7 +7726,6 @@ function computedInPlainWords(row, field) {
7661
7726
  }
7662
7727
 
7663
7728
  // src/views.ts
7664
- var import_core7 = require("@ai-matrx/records/core");
7665
7729
  var VIEW_LAYOUTS = ["grid", "kanban", "calendar", "gallery"];
7666
7730
  var LAYOUT_LABEL = {
7667
7731
  grid: "Grid",
@@ -7786,16 +7850,7 @@ function viewFromRecord(row) {
7786
7850
  };
7787
7851
  }
7788
7852
  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);
7853
+ return updateOnTheWinningVersion(client, viewId, document2, expectedVersion, "always");
7799
7854
  }
7800
7855
  function viewPatchDocument(patch) {
7801
7856
  const out = {};
@@ -16505,8 +16560,7 @@ function visibilityLaneFor(table) {
16505
16560
  if (table.is_kernel) return null;
16506
16561
  if ((table.slug ?? "").startsWith(APP_TABLE_PREFIX) || keptByTheApp(table)) return null;
16507
16562
  if (table.visibility === "personal") return "mine";
16508
- if (table.visibility === "link") return "community";
16509
- if (table.visibility === "public") return "world";
16563
+ if (table.visibility === "link" || table.visibility === "public") return "world";
16510
16564
  return "organization";
16511
16565
  }
16512
16566
  function TablesHome({ onOpenTable, className }) {