@ai-matrx/records-ui 0.9.0 → 0.10.3

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,94 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.10.3
4
+
5
+ Reissues the unpublished 0.10.2 candidate. Its live-store release gate exposed
6
+ a race in the field-add smoke harness: it unmounted each panel immediately
7
+ after clicking Save, before the asynchronous declaration had landed. The
8
+ helper now waits until the real store reports the exact new Field before
9
+ proceeding. Package behavior is unchanged.
10
+
11
+ ### Consumer action
12
+
13
+ None.
14
+
15
+ ## 0.10.2
16
+
17
+ Reissues the unpublished 0.10.1 candidate after its live-store release gate
18
+ revealed that the demo selector read `document.title` while the actual
19
+ `custom.record` row exposes the record payload as `data.title`. The smoke test
20
+ now selects Acme Industrial from the real row shape; package behavior is unchanged.
21
+
22
+ ### Consumer action
23
+
24
+ None.
25
+
26
+ ## 0.10.1
27
+
28
+ Reissues the unpublished 0.10.0 package after its live-store smoke test selected
29
+ the oldest demo record while asserting the identity of Acme Industrial. The
30
+ test now selects that record by title, so re-seeding cannot substitute another
31
+ record. Package behavior is unchanged from the 0.10.0 candidate.
32
+
33
+ ### Consumer action
34
+
35
+ None.
36
+
37
+ ## 0.10.0
38
+
39
+ **The three things the 19 September verdict (fifth pass) said a person hits the
40
+ moment they touch a table.**
41
+
42
+ **A double-click opens the typed editor, on every cell.** The verdict: "only the
43
+ Title column can be opened by double-clicking; double-clicking any other cell
44
+ opens a developer inspector — a floating window showing the record's raw JSON,
45
+ its machine keys, a truncated id, and the headings DOCUMENT, LEVEL and HIDDEN."
46
+ Two things did that. The data table ships a side panel AND a floating window,
47
+ both falling back to `DataRowInspector`; the grid now turns both OFF, because
48
+ this package has a record panel of its own (`Peek`, through `onRowOpen`) and a
49
+ second one beside it is the parallel layer the platform forbids. And a cell now
50
+ FILLS its cell and answers `dblclick` itself — the second press used to land on
51
+ bare table cell after the first had swapped the DOM under the pointer, and a
52
+ bare cell is a row click, which is exactly why a wide column worked and every
53
+ narrow one did not.
54
+
55
+ **Tab commits the typed value before it moves.** The verdict: "when we tabbed
56
+ along a row, the first five values saved and the last four were silently
57
+ dropped." The draft used to live in the open CELL, and this screen re-renders
58
+ that cell for reasons that have nothing to do with the person typing — every
59
+ accepted write calls `reload()`, which rebuilds the rows, the columns and the
60
+ cells. A cell that remounted mid-edit came back initialised from the stored
61
+ value, so the commit wrote what was already there and the screen correctly
62
+ treated it as "nothing changed". The draft now lives in the editing session,
63
+ beside the address of the open cell and the value it started from; `commit(move)`
64
+ takes no value at all, because there is only one place the typed value can be
65
+ read from. The editor is focused once per opening rather than on every render.
66
+
67
+ **"Total of linked records" can be added, and a refusal is a sentence.** The
68
+ panel now offers EVERY relation column — including the ones that hold a single
69
+ record, marked "holds one" — because hiding "Crew lead" from the list taught a
70
+ person nothing. `whatIsMissing` asks, in the panel's own words, what has not been
71
+ answered yet ("Crew lead points at one record at a time, so there is nothing to
72
+ total. Tick 'Can hold more than one' on Crew lead, or use Borrowed value to read
73
+ its one value."), the sentence sits under the button, and the panel never sends a
74
+ declaration it already knows the store must refuse.
75
+
76
+ **A hint written for an engineer never reaches a person.** A store refusal
77
+ carries a message for the person and a hint for whoever has to fix the caller
78
+ ("FLD-11: … config.via names a relation Field of this same table, by its key").
79
+ This package printed both. `hintForAPerson` now filters the hint BY SHAPE — a
80
+ contract row id, a schema-qualified database object, a path inside a document,
81
+ a snake_case identifier or a SQLSTATE — and what it filters joins the SQLSTATE on
82
+ the notice's title, for an engineer. A hint in ordinary words is still shown
83
+ verbatim.
84
+
85
+ New exports: `whatIsMissing`, `hintForAPerson`, `hintIsMachineIdentity`.
86
+
87
+ ### Consumer action
88
+
89
+ None. `GridEditing.commit` now takes `(move)` instead of `(value, move)` and
90
+ carries `draft` / `type`; nothing outside this package calls it.
91
+
3
92
  ## 0.9.0
4
93
 
5
94
  **Sharing reaches the record and table screens (SCR-N-5).** `ShareControl` puts a
package/dist/index.cjs CHANGED
@@ -134,6 +134,8 @@ __export(src_exports, {
134
134
  formDocument: () => formDocument,
135
135
  formFromRecord: () => formFromRecord,
136
136
  groupLabel: () => groupLabel,
137
+ hintForAPerson: () => hintForAPerson,
138
+ hintIsMachineIdentity: () => hintIsMachineIdentity,
137
139
  humanize: () => humanize,
138
140
  idsOf: () => idsOf,
139
141
  isId: () => isId,
@@ -166,7 +168,8 @@ __export(src_exports, {
166
168
  useViewRecords: () => useViewRecords,
167
169
  viewDocument: () => viewDocument,
168
170
  viewFromRecord: () => viewFromRecord,
169
- viewPatchDocument: () => viewPatchDocument
171
+ viewPatchDocument: () => viewPatchDocument,
172
+ whatIsMissing: () => whatIsMissing
170
173
  });
171
174
  module.exports = __toCommonJS(src_exports);
172
175
 
@@ -873,27 +876,49 @@ var TITLE = {
873
876
  store_not_served: "The store is not being served",
874
877
  internal: "The store said something this package does not recognise"
875
878
  };
879
+ var MACHINE_IDENTITY = [
880
+ /\b[A-Z]{2,4}-\d{1,3}\b/,
881
+ // FLD-11, REC-29, AGT-7
882
+ /\b(?:custom|platform|iam|history|public)\.[a-z_]+/,
883
+ // a database object
884
+ /\b(?:config|data|document|spec)\.[a-z_]+/,
885
+ // a path inside a document
886
+ /\b[a-z]+(?:_[a-z]+)+\b/,
887
+ // any snake_case identifier
888
+ // A SQLSTATE: five characters of digits and capitals, at least one a digit —
889
+ // so `23514` and `PT409` match and an emphatic `NEVER` does not.
890
+ /\b(?=[0-9A-Z]{5}\b)(?=[0-9A-Z]*\d)[0-9A-Z]{5}\b/
891
+ ];
892
+ function hintIsMachineIdentity(hint) {
893
+ return MACHINE_IDENTITY.some((shape) => shape.test(hint));
894
+ }
895
+ function hintForAPerson(hint) {
896
+ const said = (hint ?? "").trim();
897
+ if (said === "") return null;
898
+ return hintIsMachineIdentity(said) ? null : said;
899
+ }
876
900
  function RefusalNotice({
877
901
  error,
878
902
  className,
879
903
  actions
880
904
  }) {
905
+ const hint = hintForAPerson(error.hint);
906
+ const forEngineers = [
907
+ error.sqlstate ? `The store's code for this was ${error.sqlstate}.` : null,
908
+ hint === null && error.hint ? error.hint : null
909
+ ].filter(Boolean).join(" ");
881
910
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(
882
911
  import_design_system4.Alert,
883
912
  {
884
913
  variant: "destructive",
885
914
  className: (0, import_design_system4.cn)("text-xs", className),
886
- ...error.sqlstate ? { title: `The store's code for this was ${error.sqlstate}` } : {},
915
+ ...forEngineers ? { title: forEngineers } : {},
887
916
  children: [
888
917
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)(import_design_system4.AlertTitle, { className: "text-xs font-medium", children: TITLE[error.code] ?? "The store refused this" }),
889
918
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)(import_design_system4.AlertDescription, { className: "space-y-1 text-xs", children: [
890
919
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { children: error.message }),
891
- error.hint ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: "opacity-80", children: error.hint }) : null,
892
- error.sqlstate ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("p", { className: "sr-only", "data-sqlstate": error.sqlstate, children: [
893
- "The store's code for this was ",
894
- error.sqlstate,
895
- "."
896
- ] }) : null,
920
+ hint ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: "opacity-80", children: hint }) : null,
921
+ forEngineers ? /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: "sr-only", ...error.sqlstate ? { "data-sqlstate": error.sqlstate } : {}, children: forEngineers }) : null,
897
922
  actions
898
923
  ] })
899
924
  ]
@@ -901,7 +926,14 @@ function RefusalNotice({
901
926
  );
902
927
  }
903
928
  function RefusalLine({ error, className }) {
904
- return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: (0, import_design_system4.cn)("text-xs text-destructive", className), title: error.hint ?? void 0, children: error.message });
929
+ const hint = hintForAPerson(error.hint);
930
+ return /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("p", { className: (0, import_design_system4.cn)("text-xs text-destructive", className), title: error.hint ?? void 0, children: [
931
+ error.message,
932
+ hint ? /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("span", { className: "opacity-80", children: [
933
+ " ",
934
+ hint
935
+ ] }) : null
936
+ ] });
905
937
  }
906
938
 
907
939
  // src/values.tsx
@@ -1081,6 +1113,8 @@ async function versionOf(client, cache, recordId) {
1081
1113
  function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
1082
1114
  const client = (0, import_react11.useRecordsClient)();
1083
1115
  const [editing, setEditing] = (0, import_react10.useState)(null);
1116
+ const [draft, setDraft] = (0, import_react10.useState)(null);
1117
+ const startedFrom = (0, import_react10.useRef)(null);
1084
1118
  const [optimistic, setOptimistic] = (0, import_react10.useState)({});
1085
1119
  const [saving, setSaving] = (0, import_react10.useState)({});
1086
1120
  const [refusals, setRefusals] = (0, import_react10.useState)({});
@@ -1112,15 +1146,31 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
1112
1146
  (rowId, key) => refusals[cellKey(rowId, key)] ?? null,
1113
1147
  [refusals]
1114
1148
  );
1149
+ const shownValue = (0, import_react10.useCallback)(
1150
+ (rowId, key) => {
1151
+ const held = optimistic[cellKey(rowId, key)];
1152
+ if (held !== void 0) return held;
1153
+ return rows.find((r) => r.id === rowId)?.document?.[key];
1154
+ },
1155
+ [optimistic, rows]
1156
+ );
1115
1157
  const begin = (0, import_react10.useCallback)(
1116
1158
  (address) => {
1117
1159
  if (!canWrite) return;
1160
+ if (editing && editing.rowId === address.rowId && editing.key === address.key) return;
1161
+ const current = shownValue(address.rowId, address.key);
1162
+ startedFrom.current = current;
1163
+ setDraft(current);
1118
1164
  setEditing(address);
1119
1165
  void versionOf(client, versions.current, address.rowId);
1120
1166
  },
1121
- [canWrite, client]
1167
+ [canWrite, client, editing, shownValue]
1122
1168
  );
1123
- const cancel = (0, import_react10.useCallback)(() => setEditing(null), []);
1169
+ const cancel = (0, import_react10.useCallback)(() => {
1170
+ setEditing(null);
1171
+ setDraft(null);
1172
+ startedFrom.current = null;
1173
+ }, []);
1124
1174
  const writeCellNow = (0, import_react10.useCallback)(
1125
1175
  async (rowId, key, value2) => {
1126
1176
  const id = cellKey(rowId, key);
@@ -1182,21 +1232,31 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
1182
1232
  [client, reload]
1183
1233
  );
1184
1234
  const commit = (0, import_react10.useCallback)(
1185
- (value2, move = null) => {
1235
+ (move = null) => {
1186
1236
  const open = editing;
1187
1237
  if (!open) return;
1188
- const row = rows.find((r) => r.id === open.rowId);
1189
- const unchanged = row !== void 0 && sameValue(row.document?.[open.key], value2);
1190
- if (!unchanged) void writeCell(open.rowId, open.key, value2);
1238
+ const value2 = draft;
1239
+ if (!sameValue(startedFrom.current, value2)) void writeCell(open.rowId, open.key, value2);
1191
1240
  if (!move) {
1192
1241
  setEditing(null);
1242
+ setDraft(null);
1243
+ startedFrom.current = null;
1193
1244
  return;
1194
1245
  }
1195
1246
  const next = step(open, order, rowIds, move);
1247
+ if (!next) {
1248
+ setEditing(null);
1249
+ setDraft(null);
1250
+ startedFrom.current = null;
1251
+ return;
1252
+ }
1253
+ const starting = shownValue(next.rowId, next.key);
1254
+ startedFrom.current = starting;
1255
+ setDraft(starting);
1196
1256
  setEditing(next);
1197
- if (next) void versionOf(client, versions.current, next.rowId);
1257
+ void versionOf(client, versions.current, next.rowId);
1198
1258
  },
1199
- [editing, rows, writeCell, order, rowIds, client]
1259
+ [editing, draft, writeCell, order, rowIds, client, shownValue]
1200
1260
  );
1201
1261
  const retry = (0, import_react10.useCallback)(
1202
1262
  (rowId, key) => {
@@ -1251,12 +1311,18 @@ function useGridEditing({ tableId, fields, rows, reload, canWrite }) {
1251
1311
  }
1252
1312
  reload();
1253
1313
  const first = order[0];
1254
- if (first) setEditing({ rowId: written.data, key: first });
1314
+ if (first) {
1315
+ startedFrom.current = void 0;
1316
+ setDraft(void 0);
1317
+ setEditing({ rowId: written.data, key: first });
1318
+ }
1255
1319
  return null;
1256
1320
  }, [client, tableId, reload, order]);
1257
1321
  return {
1258
1322
  valueFor,
1259
1323
  editing,
1324
+ draft,
1325
+ type: setDraft,
1260
1326
  stateOf,
1261
1327
  refusalOf,
1262
1328
  begin,
@@ -1319,27 +1385,40 @@ function GridCell({
1319
1385
  OpenCell,
1320
1386
  {
1321
1387
  field,
1322
- value: value2,
1323
- onCommit: (next, move) => editing.commit(next, move),
1388
+ draft: editing.draft,
1389
+ onType: editing.type,
1390
+ onCommit: (move) => editing.commit(move),
1324
1391
  onCancel: editing.cancel
1325
1392
  }
1326
1393
  );
1327
1394
  }
1328
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { "data-matrx-cell-control": "", className: "flex min-w-0 flex-col gap-0.5", children: [
1395
+ const openThisCell = (event) => {
1396
+ event.preventDefault();
1397
+ event.stopPropagation();
1398
+ editing.begin({ rowId: row.id, key: field.key });
1399
+ };
1400
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { "data-matrx-cell-control": "", className: "flex w-full min-w-0 flex-col gap-0.5", children: [
1329
1401
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
1330
1402
  "span",
1331
1403
  {
1332
1404
  role: editable ? "button" : void 0,
1333
1405
  tabIndex: editable ? 0 : void 0,
1334
1406
  "aria-label": editable ? `Edit ${fieldName(field)}` : void 0,
1335
- title: editable ? `${fieldName(field)} \u2014 press Enter to edit` : void 0,
1407
+ title: editable ? `${fieldName(field)} \u2014 click or press Enter to edit` : `${fieldName(field)} is worked out by the system, so it cannot be typed in`,
1336
1408
  className: (0, import_design_system6.cn)(
1337
- "block min-w-0 truncate rounded px-1 py-0.5",
1409
+ // `w-full` so the whole cell is the control. A gap beside it is bare
1410
+ // table cell, and a click there is a row click.
1411
+ "block w-full min-w-0 truncate rounded px-1 py-0.5",
1338
1412
  editable ? "cursor-text hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" : "",
1339
1413
  state === "saving" ? "opacity-60" : "",
1340
1414
  state === "refused" ? "ring-1 ring-destructive" : ""
1341
1415
  ),
1342
- onClick: editable ? () => editing.begin({ rowId: row.id, key: field.key }) : void 0,
1416
+ onClick: editable ? openThisCell : void 0,
1417
+ onDoubleClick: (event) => {
1418
+ event.preventDefault();
1419
+ event.stopPropagation();
1420
+ if (editable) editing.begin({ rowId: row.id, key: field.key });
1421
+ },
1343
1422
  onKeyDown: editable ? (event) => {
1344
1423
  if (event.key === "Enter" || event.key === "F2") {
1345
1424
  event.preventDefault();
@@ -1382,11 +1461,11 @@ function closesOnBlur(field) {
1382
1461
  }
1383
1462
  function OpenCell({
1384
1463
  field,
1385
- value: value2,
1464
+ draft,
1465
+ onType,
1386
1466
  onCommit,
1387
1467
  onCancel
1388
1468
  }) {
1389
- const [draft, setDraft] = (0, import_react10.useState)(value2);
1390
1469
  const multiline = editorKindFor(field) === "long_text";
1391
1470
  const blurCommits = closesOnBlur(field);
1392
1471
  const done = (0, import_react10.useRef)(false);
@@ -1395,22 +1474,24 @@ function OpenCell({
1395
1474
  done.current = true;
1396
1475
  run();
1397
1476
  };
1477
+ const host = (0, import_react10.useRef)(null);
1478
+ (0, import_react10.useEffect)(() => {
1479
+ const control = host.current?.querySelector("input, textarea, [role='combobox'], button");
1480
+ if (control && document.activeElement !== control) control.focus();
1481
+ }, [field.id]);
1398
1482
  const blur = blurCommits ? {
1399
1483
  onBlur: (event) => {
1400
1484
  if (event.currentTarget.contains(event.relatedTarget)) return;
1401
- once(() => onCommit(draft, null));
1485
+ once(() => onCommit(null));
1402
1486
  }
1403
1487
  } : {};
1404
1488
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(
1405
1489
  "span",
1406
1490
  {
1491
+ ref: host,
1407
1492
  "data-matrx-cell-control": "",
1408
- className: "flex min-w-[10rem] flex-col gap-1",
1409
- ref: (node) => {
1410
- if (!node) return;
1411
- const control = node.querySelector("input, textarea, [role='combobox'], button");
1412
- if (control && document.activeElement !== control) control.focus();
1413
- },
1493
+ className: "flex w-full min-w-[10rem] flex-col gap-1",
1494
+ onDoubleClick: (event) => event.stopPropagation(),
1414
1495
  onKeyDown: (event) => {
1415
1496
  if (event.key === "Escape") {
1416
1497
  event.preventDefault();
@@ -1421,20 +1502,20 @@ function OpenCell({
1421
1502
  if (event.key === "Tab") {
1422
1503
  event.preventDefault();
1423
1504
  event.stopPropagation();
1424
- once(() => onCommit(draft, event.shiftKey ? "previous" : "next"));
1505
+ once(() => onCommit(event.shiftKey ? "previous" : "next"));
1425
1506
  return;
1426
1507
  }
1427
1508
  if (event.key === "Enter" && (!multiline || event.metaKey || event.ctrlKey)) {
1428
1509
  event.preventDefault();
1429
1510
  event.stopPropagation();
1430
- once(() => onCommit(draft, null));
1511
+ once(() => onCommit(null));
1431
1512
  }
1432
1513
  },
1433
1514
  ...blur,
1434
1515
  children: [
1435
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(FieldControl, { field, value: draft, onChange: setDraft, id: `cell-${field.id}` }),
1516
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(FieldControl, { field, value: draft, onChange: onType, id: `cell-${field.id}` }),
1436
1517
  blurCommits ? /* @__PURE__ */ (0, import_jsx_runtime8.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_runtime8.jsxs)("span", { className: "flex items-center gap-1", children: [
1437
- /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_design_system6.Button, { size: "sm", onClick: () => once(() => onCommit(draft, null)), children: "Save" }),
1518
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_design_system6.Button, { size: "sm", onClick: () => once(() => onCommit(null)), children: "Save" }),
1438
1519
  /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_design_system6.Button, { size: "sm", variant: "ghost", onClick: () => once(onCancel), children: "Cancel" })
1439
1520
  ] })
1440
1521
  ]
@@ -1523,6 +1604,8 @@ function Grid({
1523
1604
  tableId: `records-grid-${tableId}`,
1524
1605
  stickyHeader: true,
1525
1606
  pageSize,
1607
+ detail: { enabled: false },
1608
+ window: { enabled: false },
1526
1609
  ...onOpenRecord ? { onRowOpen: (row) => onOpenRecord(row.id) } : {},
1527
1610
  ...canWrite ? {
1528
1611
  rowActions: (row) => /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(
@@ -2326,7 +2409,6 @@ function FieldEditor({ tableId, field, onSaved, onCancel, onRemoved, className }
2326
2409
  [fields.data, field?.id]
2327
2410
  );
2328
2411
  const relations = (0, import_react25.useMemo)(() => others.filter((f) => f.type === "relation"), [others]);
2329
- const manyRelations = (0, import_react25.useMemo)(() => relations.filter((f) => f.multi), [relations]);
2330
2412
  const choice = fieldTypeChoice(type);
2331
2413
  const editing = Boolean(field);
2332
2414
  const derivedKey = field?.key ?? keyFor(label);
@@ -2334,7 +2416,9 @@ function FieldEditor({ tableId, field, onSaved, onCancel, onRemoved, className }
2334
2416
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { className: (0, import_design_system12.cn)("text-xs text-muted-foreground", className), children: rights.reason });
2335
2417
  }
2336
2418
  const options = optionText.split("\n").map((word) => word.trim()).filter((word) => word !== "");
2419
+ const missing = editing ? null : whatIsMissing({ type, label, options, via, pick, agg, of, relations, formulaFields });
2337
2420
  const save = async () => {
2421
+ if (missing) return;
2338
2422
  if (editing && field) {
2339
2423
  const patch = {
2340
2424
  label,
@@ -2440,7 +2524,6 @@ function FieldEditor({ tableId, field, onSaved, onCancel, onRemoved, className }
2440
2524
  formulaFields,
2441
2525
  setFormulaFields,
2442
2526
  relations,
2443
- manyRelations,
2444
2527
  others,
2445
2528
  existingOptionsTable: Boolean(field?.options_table_id ?? field?.config?.["options_table_id"])
2446
2529
  }
@@ -2555,8 +2638,17 @@ function FieldEditor({ tableId, field, onSaved, onCancel, onRemoved, className }
2555
2638
  ] }, `${rule.kind}-${index}`))
2556
2639
  ] }),
2557
2640
  mutation.error ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(RefusalNotice, { error: mutation.error }) : null,
2641
+ missing ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { className: "text-[11px] text-destructive", children: missing }) : null,
2558
2642
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex flex-wrap items-center gap-2", children: [
2559
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.Button, { size: "sm", disabled: mutation.saving || label.trim() === "", onClick: () => void save(), children: mutation.saving ? "Saving\u2026" : field ? "Save field" : "Add field" }),
2643
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(
2644
+ import_design_system12.Button,
2645
+ {
2646
+ size: "sm",
2647
+ disabled: mutation.saving || missing !== null,
2648
+ onClick: () => void save(),
2649
+ children: mutation.saving ? "Saving\u2026" : field ? "Save field" : "Add field"
2650
+ }
2651
+ ),
2560
2652
  onCancel ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.Button, { size: "sm", variant: "ghost", onClick: onCancel, children: "Cancel" }) : null,
2561
2653
  field ? /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("div", { className: "ml-auto flex items-center gap-2", children: askingToRemove ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_jsx_runtime14.Fragment, { children: [
2562
2654
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("span", { className: "text-destructive", children: [
@@ -2570,6 +2662,34 @@ function FieldEditor({ tableId, field, onSaved, onCancel, onRemoved, className }
2570
2662
  ] })
2571
2663
  ] });
2572
2664
  }
2665
+ function whatIsMissing(state) {
2666
+ const { type, label, options, via, pick, agg, of, relations, formulaFields } = state;
2667
+ if (label.trim() === "") return "Give the column a name.";
2668
+ if (type === "select" || type === "multi_select") {
2669
+ if (options.length === 0) return "Type the choices this column offers, one per line.";
2670
+ return null;
2671
+ }
2672
+ if (type === "lookup" || type === "rollup") {
2673
+ if (relations.length === 0) {
2674
+ return type === "rollup" ? "A total is worked out across the records a column points at, and this table has no column that points at records yet. Add a Person or File column that can hold more than one, then come back." : "A borrowed value is read from a record a column points at, and this table has no column that points at another record yet. Add a Person or File column, then come back.";
2675
+ }
2676
+ const chosen = relations.find((relation) => relation.key === via);
2677
+ if (!chosen) {
2678
+ return type === "rollup" ? "Choose the column whose records this one adds up." : "Choose the column whose record this one reads from.";
2679
+ }
2680
+ if (type === "rollup" && !chosen.multi) {
2681
+ return `${fieldName(chosen)} points at one record at a time, so there is nothing to total. Tick "Can hold more than one" on ${fieldName(chosen)}, or use Borrowed value to read its one value.`;
2682
+ }
2683
+ if (type === "lookup" && pick.trim() === "") {
2684
+ return `Say which value to read from the record ${fieldName(chosen)} points at.`;
2685
+ }
2686
+ if (type === "rollup" && agg !== "count" && of.trim() === "") {
2687
+ return "Say which value of those records to work out.";
2688
+ }
2689
+ return null;
2690
+ }
2691
+ return null;
2692
+ }
2573
2693
  function multiApplies(type) {
2574
2694
  return type === "member" || type === "attachment" || type === "text";
2575
2695
  }
@@ -2635,16 +2755,19 @@ function TypeQuestions(props) {
2635
2755
  ] });
2636
2756
  }
2637
2757
  if (type === "lookup" || type === "rollup") {
2638
- const usable = type === "rollup" ? props.manyRelations : props.relations;
2758
+ const usable = props.relations;
2639
2759
  if (usable.length === 0) {
2640
- return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { className: "text-[11px] text-muted-foreground", children: type === "rollup" ? "This kind of column works something out from records this one points at, and this table has no column yet that points at several records. Add a Person or File column that can hold more than one first." : "This kind of column reads a value from a record this one points at, and this table has no column yet that points at another record. Add a Person or File column first." });
2760
+ return /* @__PURE__ */ (0, import_jsx_runtime14.jsx)("p", { className: "text-[11px] text-muted-foreground", children: type === "rollup" ? "This kind of column works something out from the records another column points at, and this table has no column that points at records yet. Add a Person or File column that can hold more than one first." : "This kind of column reads a value from a record another column points at, and this table has no column that points at another record yet. Add a Person or File column first." });
2641
2761
  }
2642
2762
  return /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "grid gap-3 sm:grid-cols-2", children: [
2643
2763
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("div", { className: "flex flex-col gap-1", children: [
2644
2764
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.Label, { className: "text-xs", children: type === "rollup" ? "Across the records in" : "Read it from" }),
2645
2765
  /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_design_system12.Select, { value: props.via, onValueChange: props.setVia, children: [
2646
2766
  /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.SelectTrigger, { size: "sm", "aria-label": type === "rollup" ? "Across the records in" : "Read it from", children: /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.SelectValue, { placeholder: "Choose a column" }) }),
2647
- /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.SelectContent, { children: usable.map((f) => /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.SelectItem, { value: f.key, children: fieldName(f) }, f.id)) })
2767
+ /* @__PURE__ */ (0, import_jsx_runtime14.jsx)(import_design_system12.SelectContent, { children: usable.map((f) => /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)(import_design_system12.SelectItem, { value: f.key, children: [
2768
+ fieldName(f),
2769
+ type === "rollup" && !f.multi ? " \u2014 holds one" : ""
2770
+ ] }, f.id)) })
2648
2771
  ] })
2649
2772
  ] }),
2650
2773
  type === "lookup" ? /* @__PURE__ */ (0, import_jsx_runtime14.jsxs)("label", { className: "flex flex-col gap-1", children: [