@ai-matrx/records-ui 0.6.0 → 0.7.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,48 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.7.0
4
+
5
+ What the first headless walk of 0.6.0 through a real browser found, in the same
6
+ hour — every one of them on a table a person had just made.
7
+
8
+ - **A new table takes an empty record.** `DEFAULT_FIELDS` made `title`
9
+ REQUIRED, so "New record" — which writes an empty record and opens its first
10
+ cell — was refused (`REC-51: Title is required`) on every table the app makes.
11
+ A title is what makes a record a chip (REC-2), not a promise it can never be
12
+ written without; an organization that wants it demanded says so in the field
13
+ editor and the store then enforces it for everyone.
14
+ - **`TableSettings` can delete the table.** SCR-3 claims rename, reorder,
15
+ retype and delete; the panel only ever deleted a FIELD, so a table made in the
16
+ product could not be got rid of from anywhere in it. It is the same soft
17
+ delete every record gets, through the same door, and the consequence is named
18
+ before the second press. `onDeleted` lets the host leave the page, and
19
+ `TablePage` passes its `onLeave`.
20
+ - **Nobody conflicts with themselves any more.** Three separate paths wrote a
21
+ record twice against one version and were — correctly — refused `PT409` by the
22
+ store, so the screen showed a person a conflict with nobody but themselves:
23
+ Tab fired the cell's key handler AND its blur handler; two cells of one record
24
+ were written in parallel; and `useRecordVersion` never moved after a save, so
25
+ switching a saved view's layout twice, or saving a form twice, conflicted with
26
+ the save before it. One commit per opening, one write at a time per record,
27
+ and every successful write hands its new version back.
28
+ - **The demo harness renders like the product.** It imported the design
29
+ system's structural sheet and nothing else, so every page was unstyled HTML in
30
+ a real browser while `pnpm test` was green. It now runs Tailwind over this
31
+ package's own source, and the address bar takes `?demo=` and `?table=` so a
32
+ walk can make a throwaway table, work in it and delete it again.
33
+
34
+ ### Consumer action
35
+
36
+ **`useRecordVersion` now answers `{ version, note }` rather than a number.** A
37
+ screen that writes the same record more than once must hand each successful
38
+ write's new version back through `note(...)`, or the next write will be refused
39
+ as a conflict with the person who just made the last one. Nothing else changed:
40
+ `version` is the same value the hook used to return, and `null` still means "no
41
+ version was read", which is the store's ordinary last-writer-wins write.
42
+
43
+ A host that shows `TableSettings` on a page about one table should pass
44
+ `onDeleted`, or the person is left on a page about a table that is gone.
45
+
3
46
  ## 0.6.0
4
47
 
5
48
  Editing where the value is shown, and four other things an independent walk of
package/dist/index.cjs CHANGED
@@ -937,6 +937,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
937
937
  const [rowError, setRowError] = (0, import_react8.useState)(null);
938
938
  const [busy, setBusy] = (0, import_react8.useState)(false);
939
939
  const versions = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
940
+ const queues = (0, import_react8.useRef)(/* @__PURE__ */ new Map());
940
941
  const editable = (0, import_react8.useMemo)(() => fields.filter(fieldIsEditable), [fields]);
941
942
  const order = (0, import_react8.useMemo)(() => editable.map((f) => f.key), [editable]);
942
943
  const rowIds = (0, import_react8.useMemo)(() => rows.map((r) => r.id), [rows]);
@@ -970,7 +971,7 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
970
971
  [canWrite, client]
971
972
  );
972
973
  const cancel = (0, import_react8.useCallback)(() => setEditing(null), []);
973
- const writeCell = (0, import_react8.useCallback)(
974
+ const writeCellNow = (0, import_react8.useCallback)(
974
975
  async (rowId, key, value2) => {
975
976
  const id = cellKey(rowId, key);
976
977
  setOptimistic((held) => ({ ...held, [id]: value2 }));
@@ -1014,6 +1015,22 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
1014
1015
  },
1015
1016
  [client, reload]
1016
1017
  );
1018
+ const writeCell = (0, import_react8.useCallback)(
1019
+ (rowId, key, value2) => {
1020
+ const behind = queues.current.get(rowId) ?? Promise.resolve();
1021
+ const next = behind.then(() => writeCellNow(rowId, key, value2));
1022
+ queues.current.set(
1023
+ rowId,
1024
+ next.then(
1025
+ () => void 0,
1026
+ () => void 0
1027
+ )
1028
+ );
1029
+ return next;
1030
+ },
1031
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1032
+ [client, reload]
1033
+ );
1017
1034
  const commit = (0, import_react8.useCallback)(
1018
1035
  (value2, move = null) => {
1019
1036
  const open = editing;
@@ -1222,10 +1239,16 @@ function OpenCell({
1222
1239
  const [draft, setDraft] = (0, import_react8.useState)(value2);
1223
1240
  const multiline = editorKindFor(field) === "long_text";
1224
1241
  const blurCommits = closesOnBlur(field);
1242
+ const done = (0, import_react8.useRef)(false);
1243
+ const once = (run) => {
1244
+ if (done.current) return;
1245
+ done.current = true;
1246
+ run();
1247
+ };
1225
1248
  const blur = blurCommits ? {
1226
1249
  onBlur: (event) => {
1227
1250
  if (event.currentTarget.contains(event.relatedTarget)) return;
1228
- onCommit(draft, null);
1251
+ once(() => onCommit(draft, null));
1229
1252
  }
1230
1253
  } : {};
1231
1254
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)(
@@ -1242,27 +1265,27 @@ function OpenCell({
1242
1265
  if (event.key === "Escape") {
1243
1266
  event.preventDefault();
1244
1267
  event.stopPropagation();
1245
- onCancel();
1268
+ once(onCancel);
1246
1269
  return;
1247
1270
  }
1248
1271
  if (event.key === "Tab") {
1249
1272
  event.preventDefault();
1250
1273
  event.stopPropagation();
1251
- onCommit(draft, event.shiftKey ? "previous" : "next");
1274
+ once(() => onCommit(draft, event.shiftKey ? "previous" : "next"));
1252
1275
  return;
1253
1276
  }
1254
1277
  if (event.key === "Enter" && (!multiline || event.metaKey || event.ctrlKey)) {
1255
1278
  event.preventDefault();
1256
1279
  event.stopPropagation();
1257
- onCommit(draft, null);
1280
+ once(() => onCommit(draft, null));
1258
1281
  }
1259
1282
  },
1260
1283
  ...blur,
1261
1284
  children: [
1262
1285
  /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(FieldControl, { field, value: draft, onChange: setDraft, id: `cell-${field.id}` }),
1263
1286
  blurCommits ? /* @__PURE__ */ (0, import_jsx_runtime7.jsx)("span", { className: "text-[10px] text-muted-foreground", children: multiline ? "\u2318\u23CE saves \xB7 Esc cancels \xB7 Tab next" : "\u23CE saves \xB7 Esc cancels \xB7 Tab next" }) : /* @__PURE__ */ (0, import_jsx_runtime7.jsxs)("span", { className: "flex items-center gap-1", children: [
1264
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_design_system6.Button, { size: "sm", onClick: () => onCommit(draft, null), children: "Save" }),
1265
- /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_design_system6.Button, { size: "sm", variant: "ghost", onClick: onCancel, children: "Cancel" })
1287
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_design_system6.Button, { size: "sm", onClick: () => once(() => onCommit(draft, null)), children: "Save" }),
1288
+ /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(import_design_system6.Button, { size: "sm", variant: "ghost", onClick: () => once(onCancel), children: "Cancel" })
1266
1289
  ] })
1267
1290
  ]
1268
1291
  }
@@ -1872,7 +1895,7 @@ function useRecordVersion(recordId) {
1872
1895
  cancelled = true;
1873
1896
  };
1874
1897
  }, [client, recordId]);
1875
- return version;
1898
+ return { version, note: setVersion };
1876
1899
  }
1877
1900
 
1878
1901
  // src/RecordForm.tsx
@@ -1891,7 +1914,8 @@ function RecordForm({
1891
1914
  const mutation = (0, import_react20.useRecordMutation)();
1892
1915
  const [draft, setDraft] = (0, import_react19.useState)({});
1893
1916
  const [touched, setTouched] = (0, import_react19.useState)(false);
1894
- const loadedVersion = useRecordVersion(recordId ?? null);
1917
+ const loaded = useRecordVersion(recordId ?? null);
1918
+ const loadedVersion = loaded.version;
1895
1919
  (0, import_react19.useEffect)(() => {
1896
1920
  const document3 = existing.data?.document;
1897
1921
  if (document3) setDraft({ ...document3 });
@@ -1920,6 +1944,7 @@ function RecordForm({
1920
1944
  ...version === null ? {} : { expectedVersion: version }
1921
1945
  });
1922
1946
  if (moved !== null) {
1947
+ loaded.note(moved);
1923
1948
  host.notify?.success("Saved");
1924
1949
  existing.reload();
1925
1950
  onSaved?.(recordId);
@@ -2277,6 +2302,7 @@ function TableSettings({
2277
2302
  proposals,
2278
2303
  onAcceptProposal,
2279
2304
  onRejectProposal,
2305
+ onDeleted,
2280
2306
  className
2281
2307
  }) {
2282
2308
  const table = (0, import_react26.useTable)(tableId);
@@ -2284,6 +2310,7 @@ function TableSettings({
2284
2310
  const rights = useTableRights(table.data);
2285
2311
  const mutation = (0, import_react26.useRecordMutation)();
2286
2312
  const [editing, setEditing] = (0, import_react25.useState)(null);
2313
+ const [askingToDelete, setAskingToDelete] = (0, import_react25.useState)(false);
2287
2314
  if (table.error) return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(RefusalNotice, { error: table.error, className });
2288
2315
  if (fields.error) return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(RefusalNotice, { error: fields.error, className });
2289
2316
  if (!rights.admin) return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { className: (0, import_design_system13.cn)("text-xs text-muted-foreground", className), children: rights.reason });
@@ -2300,6 +2327,11 @@ function TableSettings({
2300
2327
  await mutation.remove({ record_id: field.id });
2301
2328
  fields.reload();
2302
2329
  };
2330
+ const deleteTable = async () => {
2331
+ const gone = await mutation.remove({ record_id: tableId });
2332
+ setAskingToDelete(false);
2333
+ if (gone !== null) onDeleted?.();
2334
+ };
2303
2335
  if (editing) {
2304
2336
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2305
2337
  FieldEditor,
@@ -2375,7 +2407,28 @@ function TableSettings({
2375
2407
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Badge, { variant: "secondary", className: "text-[10px] font-normal", children: proposal.proposed_by }),
2376
2408
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Button, { size: "sm", variant: "outline", onClick: () => onAcceptProposal?.(proposal), children: "Accept" }),
2377
2409
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Button, { size: "sm", variant: "ghost", onClick: () => onRejectProposal?.(proposal), children: "Reject" })
2378
- ] }, proposal.id)) })
2410
+ ] }, proposal.id)) }),
2411
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Separator, {}),
2412
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex flex-col gap-1", children: [
2413
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("span", { className: "font-medium", children: "This table" }),
2414
+ askingToDelete ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
2415
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("p", { className: "text-muted-foreground", children: [
2416
+ "Deleting ",
2417
+ table.data ? tableName(table.data) : "this table",
2418
+ " takes it out of everyone's list, with its ",
2419
+ rows.length,
2420
+ " field",
2421
+ rows.length === 1 ? "" : "s",
2422
+ " and every record in it.",
2423
+ table.data?.retention_days ? ` The store keeps them for ${table.data.retention_days} days, so it can still be restored.` : " The store keeps them, so it can still be restored."
2424
+ ] }),
2425
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex gap-1", children: [
2426
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Button, { size: "sm", variant: "destructive", disabled: mutation.saving, onClick: () => void deleteTable(), children: mutation.saving ? "Deleting\u2026" : "Delete this table" }),
2427
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Button, { size: "sm", variant: "ghost", onClick: () => setAskingToDelete(false), children: "Keep it" })
2428
+ ] })
2429
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system13.Button, { size: "sm", variant: "outline", onClick: () => setAskingToDelete(true), children: "Delete this table" }) }),
2430
+ mutation.error ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(RefusalNotice, { error: mutation.error }) : null
2431
+ ] })
2379
2432
  ] });
2380
2433
  }
2381
2434
 
@@ -3877,7 +3930,7 @@ function FormBuilder({ tableId, seed, activeFormId, onActiveForm, className }) {
3877
3930
  const [draft, setDraft] = (0, import_react46.useState)(null);
3878
3931
  const [saving, setSaving] = (0, import_react46.useState)(false);
3879
3932
  const [saved, setSaved] = (0, import_react46.useState)(null);
3880
- const loadedVersion = useRecordVersion(activeId);
3933
+ const loaded = useRecordVersion(activeId);
3881
3934
  const formTableId = home.tableId;
3882
3935
  const load = (0, import_react46.useCallback)(async () => {
3883
3936
  if (!formTableId) return;
@@ -3941,13 +3994,14 @@ function FormBuilder({ tableId, seed, activeFormId, onActiveForm, className }) {
3941
3994
  record_id: draft.id,
3942
3995
  patch: document2,
3943
3996
  // The form record's own version: two people editing one form are TOLD.
3944
- ...loadedVersion === null ? {} : { expectedVersion: loadedVersion }
3997
+ ...loaded.version === null ? {} : { expectedVersion: loaded.version }
3945
3998
  });
3946
3999
  setSaving(false);
3947
4000
  if (!written.ok) {
3948
4001
  setError(written.error);
3949
4002
  return;
3950
4003
  }
4004
+ loaded.note(written.data);
3951
4005
  setSaved("Saved.");
3952
4006
  await load();
3953
4007
  }
@@ -5050,7 +5104,7 @@ function DashboardCanvas({ tableId, seed, activeDashboardId, className }) {
5050
5104
  const [boards, setBoards] = (0, import_react59.useState)(null);
5051
5105
  const [error, setError] = (0, import_react59.useState)(null);
5052
5106
  const [activeId, setActiveId] = (0, import_react59.useState)(activeDashboardId ?? null);
5053
- const loadedVersion = useRecordVersion(activeId);
5107
+ const loaded = useRecordVersion(activeId);
5054
5108
  const boardTableId = home.tableId;
5055
5109
  const load = (0, import_react59.useCallback)(async () => {
5056
5110
  if (!boardTableId) return;
@@ -5096,12 +5150,13 @@ function DashboardCanvas({ tableId, seed, activeDashboardId, className }) {
5096
5150
  record_id: board2.id,
5097
5151
  patch: dashboardDocument({ ...board2, blocks }),
5098
5152
  // The dashboard record's own version: two tabs editing one board are TOLD.
5099
- ...loadedVersion === null ? {} : { expectedVersion: loadedVersion }
5153
+ ...loaded.version === null ? {} : { expectedVersion: loaded.version }
5100
5154
  });
5101
5155
  if (!updated.ok) {
5102
5156
  setError(updated.error);
5103
5157
  return;
5104
5158
  }
5159
+ loaded.note(updated.data);
5105
5160
  await load();
5106
5161
  }
5107
5162
  async function create() {
@@ -6556,7 +6611,7 @@ function tokenFor(name) {
6556
6611
  return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "").slice(0, 48) || "table";
6557
6612
  }
6558
6613
  var DEFAULT_FIELDS = [
6559
- { key: "title", label: "Title", type: "text", sort: 10, required: true }
6614
+ { key: "title", label: "Title", type: "text", sort: 10, required: false }
6560
6615
  ];
6561
6616
  async function declareTable(client, spec) {
6562
6617
  const fields = spec.fields && spec.fields.length > 0 ? spec.fields : DEFAULT_FIELDS;
@@ -6782,11 +6837,12 @@ function TablePage({
6782
6837
  setView({ ...current, ...patch });
6783
6838
  const document2 = viewPatchDocument(patch);
6784
6839
  if (Object.keys(document2).length === 0) return;
6785
- await client.recordUpdate({
6840
+ const written = await client.recordUpdate({
6786
6841
  record_id: current.id,
6787
6842
  patch: document2,
6788
- ...viewVersion === null ? {} : { expectedVersion: viewVersion }
6843
+ ...viewVersion.version === null ? {} : { expectedVersion: viewVersion.version }
6789
6844
  });
6845
+ if (written.ok) viewVersion.note(written.data);
6790
6846
  },
6791
6847
  [client, view, viewVersion]
6792
6848
  );
@@ -6839,7 +6895,13 @@ function TablePage({
6839
6895
  ] })
6840
6896
  ] }),
6841
6897
  rail === "none" ? null : /* @__PURE__ */ (0, import_jsx_runtime40.jsxs)("aside", { className: "w-[26rem] shrink-0 overflow-y-auto rounded-md border p-3", children: [
6842
- rail === "settings" ? /* @__PURE__ */ (0, import_jsx_runtime40.jsx)(TableSettings, { tableId }) : null,
6898
+ rail === "settings" ? /* @__PURE__ */ (0, import_jsx_runtime40.jsx)(
6899
+ TableSettings,
6900
+ {
6901
+ tableId,
6902
+ ...onLeave ? { onDeleted: onLeave } : {}
6903
+ }
6904
+ ) : null,
6843
6905
  rail === "inbox" ? /* @__PURE__ */ (0, import_jsx_runtime40.jsx)(
6844
6906
  ActionInbox,
6845
6907
  {