@ai-matrx/records-ui 0.69.0 → 0.71.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,60 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.71.0
4
+
5
+ **A public form no longer takes the page down when it asks for a choice, a linked record or a
6
+ person.** (lane FIX-10A, from VERIFIER-10 finding F1.)
7
+
8
+ An agent built and published a job-request form for a plumbing company in forty seconds and
9
+ handed over its link. Opened by a signed-out stranger — the only person that link is for — it
10
+ answered **HTTP 500**, on production, with `useRecordsClient was called outside
11
+ <RecordsProvider>` in the console.
12
+
13
+ The public arm of `FormRunner` is deliberately the arm that mounts **no store client**: the
14
+ server resolved the Fields through `custom.form_public`, and the browser on that page holds no
15
+ key, knows no organization and may not read a row. `useOptionalRecordsClient` exists in
16
+ `@ai-matrx/records` for exactly that screen. But three of the editors `FieldControl` can draw
17
+ asked for the client with `useRecordsClient`, which throws by design — so **any** public form
18
+ carrying a drop-down, a relation or a person question died, whatever it was about.
19
+
20
+ * **`OptionControl`, `RelationPicker` and `PersonPicker` now take the optional client** and,
21
+ with none mounted, are **absent and honest** instead of fatal. A choice question takes the
22
+ answer as text and says why the list cannot be read on a page answered without an account; a
23
+ relation question says it cannot be answered here and to leave it blank; a person question
24
+ already had that sentence and now reaches it instead of throwing above it.
25
+ * **The guard** — `src/a-public-form-needs-no-store-client.test.tsx` — walks `FIELD_KINDS`, the
26
+ store's own closed list of the nineteen kinds a Field can be, and renders the public arm with
27
+ a question of every one of them and no provider of any sort above it. Red on the bytes before
28
+ this release (4 of 19), green on these. A twentieth kind whose editor reaches for the store
29
+ turns it red the day it is added, not the day a stranger opens a link.
30
+
31
+ ## 0.70.0
32
+
33
+ **The bar that said four now opens four.** (lane DRILL.)
34
+
35
+ * **`TablePage` takes a `filter`** — the store's one `RecordFilter` — and hands it to the grid
36
+ AND the board, so switching layout after a drill-through does not quietly widen back to the
37
+ whole table. It is a question for this visit, like `activeView` and `activeGroupField`, and
38
+ is never written onto the saved view. `Grid` and `ViewSwitcher` (and `useViewRecords`) take
39
+ it too; the narrowing happens in the STORE, never in the browser, because this screen holds
40
+ one page and a browser-side filter would drop every match on page two.
41
+ * **`cameFromLine` says the narrow thing, and means it.** TAILS-6 shipped *"This is every
42
+ record grouped into the same columns the chart used — not only the ones that number
43
+ counted"*, which was TRUE and useless: no door took a filter. It now reads *"These are only
44
+ the records that number counted: Status is Cancelled. Everything else in this table is being
45
+ held back"*, and it still refuses to claim narrowing when no question came with the click.
46
+ * **`filterInWords`** turns the question into the words of the person whose business it is —
47
+ the Field's own name through the same `fieldName` the column headers use, never a column
48
+ key. `null` becomes "Status is not filled in"; a window becomes "from … up to …", because
49
+ the store's windows are half-open and "to 2026-02-01" would read as including February 1st.
50
+ * **`DigestScheduler.filters` is a `RecordFilter`**, and `DashboardCanvas`'s
51
+ `as Record<string, unknown>` is gone — that cast was the seam where the dashboard's filter,
52
+ the saved view it schedules over and the drill-through it opens could have drifted apart.
53
+ * **Guard `a-number-opens-only-its-own-rows.test.tsx`** asserts the DOOR the page opened and
54
+ the argument it carried, and refuses by name the moment anything opens the unfiltered
55
+ `read_records` while holding a filter. Shown RED on the pre-fix bytes (four clauses,
56
+ including the old sentence) and green on these.
57
+
3
58
  ## 0.69.0
4
59
 
5
60
  **She can make the one that is missing, from where she is looking for it.** (lane TAILS-7.)
package/dist/index.cjs CHANGED
@@ -919,8 +919,9 @@ var import_react5 = require("react");
919
919
  var import_react6 = require("@ai-matrx/records/react");
920
920
  var import_design_system2 = require("@ai-matrx/design-system");
921
921
  var import_jsx_runtime3 = require("react/jsx-runtime");
922
+ var NO_STORE_ON_THIS_SCREEN_REASON = "cannot be answered on this page: it points at a record in somebody's own data, and a page answered without an account is never allowed to search that. Leave it blank and whoever receives this will fill it in.";
922
923
  function RelationPicker({ field, value, onChange, disabled, id, className, allowCreate = true }) {
923
- const client = (0, import_react6.useRecordsClient)();
924
+ const client = (0, import_react6.useOptionalRecordsClient)();
924
925
  const [rows, setRows] = (0, import_react5.useState)(null);
925
926
  const [words, setWords] = (0, import_react5.useState)({});
926
927
  const [refusal, setRefusal] = (0, import_react5.useState)(null);
@@ -938,7 +939,7 @@ function RelationPicker({ field, value, onChange, disabled, id, className, allow
938
939
  );
939
940
  (0, import_react5.useEffect)(() => {
940
941
  const target2 = field.relation_target;
941
- if (!target2) return;
942
+ if (!target2 || !client) return;
942
943
  let cancelled = false;
943
944
  void client.list({ table_id: target2, limit: 200 }).then((result) => {
944
945
  if (cancelled) return;
@@ -950,7 +951,7 @@ function RelationPicker({ field, value, onChange, disabled, id, className, allow
950
951
  };
951
952
  }, [client, field.relation_target]);
952
953
  (0, import_react5.useEffect)(() => {
953
- if (!rows) return;
954
+ if (!rows || !client) return;
954
955
  const wanted = [.../* @__PURE__ */ new Set([...rows.map((row) => row.id), ...picked])].filter(
955
956
  (id2) => words[id2] === void 0
956
957
  );
@@ -970,7 +971,7 @@ function RelationPicker({ field, value, onChange, disabled, id, className, allow
970
971
  };
971
972
  }, [client, field.id, rows, picked, words]);
972
973
  (0, import_react5.useEffect)(() => {
973
- if (!open || !allowCreate || target || targetRefusal) return;
974
+ if (!open || !allowCreate || target || targetRefusal || !client) return;
974
975
  const id2 = field.relation_target;
975
976
  if (!id2) return;
976
977
  let cancelled = false;
@@ -991,6 +992,13 @@ function RelationPicker({ field, value, onChange, disabled, id, className, allow
991
992
  cancelled = true;
992
993
  };
993
994
  }, [client, open, allowCreate, target, targetRefusal, field.relation_target]);
995
+ if (!client) {
996
+ return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("p", { id, className: (0, import_design_system2.cn)("text-xs text-muted-foreground", className), children: [
997
+ fieldName(field),
998
+ " ",
999
+ NO_STORE_ON_THIS_SCREEN_REASON
1000
+ ] });
1001
+ }
994
1002
  if (!field.relation_target) {
995
1003
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)("p", { className: "text-xs text-muted-foreground", children: [
996
1004
  fieldName(field),
@@ -1321,10 +1329,11 @@ function JsonControl({ field, value, onChange, disabled, id }) {
1321
1329
  ] });
1322
1330
  }
1323
1331
  function OptionControl({ field, value, onChange, disabled, id }) {
1324
- const client = (0, import_react8.useRecordsClient)();
1332
+ const client = (0, import_react8.useOptionalRecordsClient)();
1325
1333
  const [options, setOptions] = (0, import_react7.useState)(null);
1326
1334
  const [refusal, setRefusal] = (0, import_react7.useState)(null);
1327
1335
  (0, import_react7.useEffect)(() => {
1336
+ if (!client) return;
1328
1337
  let cancelled = false;
1329
1338
  void client.fieldOptions({ field_id: field.id }).then((result) => {
1330
1339
  if (cancelled) return;
@@ -1335,6 +1344,23 @@ function OptionControl({ field, value, onChange, disabled, id }) {
1335
1344
  cancelled = true;
1336
1345
  };
1337
1346
  }, [client, field.id]);
1347
+ if (!client) {
1348
+ return /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("div", { className: "flex flex-col gap-1", children: [
1349
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
1350
+ import_design_system3.BasicInput,
1351
+ {
1352
+ id,
1353
+ value: value === null || value === void 0 ? "" : String(value),
1354
+ disabled,
1355
+ onChange: (e) => onChange(e.target.value === "" ? null : e.target.value)
1356
+ }
1357
+ ),
1358
+ /* @__PURE__ */ (0, import_jsx_runtime4.jsxs)("p", { className: "text-xs text-muted-foreground", children: [
1359
+ fieldName(field),
1360
+ " is normally picked from a list. This page is answered without an account, so the list cannot be read here \u2014 type your answer and whoever receives it will match it up."
1361
+ ] })
1362
+ ] });
1363
+ }
1338
1364
  if (refusal) return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { id, className: "text-xs text-destructive", children: refusal });
1339
1365
  if (!options) return /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { id, className: "text-xs text-muted-foreground", children: "Reading this field's choices\u2026" });
1340
1366
  if (options.length === 0) {
@@ -1419,7 +1445,7 @@ async function personRecordForMember(client, personTableId, people, member) {
1419
1445
  return written;
1420
1446
  }
1421
1447
  function PersonPicker({ field, value, onChange, disabled, id, className }) {
1422
- const client = (0, import_react10.useRecordsClient)();
1448
+ const client = (0, import_react10.useOptionalRecordsClient)();
1423
1449
  const host = useRecordsUi();
1424
1450
  const [members, setMembers] = (0, import_react9.useState)(null);
1425
1451
  const [people, setPeople] = (0, import_react9.useState)([]);
@@ -1434,7 +1460,7 @@ function PersonPicker({ field, value, onChange, disabled, id, className }) {
1434
1460
  [value]
1435
1461
  );
1436
1462
  const load = (0, import_react9.useCallback)(async () => {
1437
- if (!membersPort) return;
1463
+ if (!membersPort || !client) return;
1438
1464
  const [roster, records] = await Promise.all([
1439
1465
  membersPort(),
1440
1466
  personTable ? client.list({ table_id: personTable, limit: 500 }) : Promise.resolve(null)
@@ -1451,7 +1477,7 @@ function PersonPicker({ field, value, onChange, disabled, id, className }) {
1451
1477
  void load();
1452
1478
  }, [open, load]);
1453
1479
  (0, import_react9.useEffect)(() => {
1454
- if (picked.length === 0 || !personTable || people.length > 0) return;
1480
+ if (picked.length === 0 || !personTable || people.length > 0 || !client) return;
1455
1481
  let cancelled = false;
1456
1482
  void client.list({ table_id: personTable, limit: 500 }).then((result) => {
1457
1483
  if (cancelled || !result.ok) return;
@@ -1461,7 +1487,7 @@ function PersonPicker({ field, value, onChange, disabled, id, className }) {
1461
1487
  cancelled = true;
1462
1488
  };
1463
1489
  }, [client, personTable, picked.length, people.length]);
1464
- if (!membersPort) {
1490
+ if (!membersPort || !client) {
1465
1491
  return /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("p", { className: (0, import_design_system4.cn)("text-xs text-muted-foreground", className), children: NO_MEMBERS_REASON });
1466
1492
  }
1467
1493
  if (!personTable) {
@@ -3557,6 +3583,7 @@ var NO_COLUMNS_WHY = "A column is what a record holds \u2014 a name, a date, an
3557
3583
  function Grid({
3558
3584
  tableId,
3559
3585
  pageSize = 50,
3586
+ filter,
3560
3587
  onOpenRecord,
3561
3588
  onAddField,
3562
3589
  onNewRecordForm,
@@ -3572,7 +3599,7 @@ function Grid({
3572
3599
  const table = (0, import_react21.useTable)(tableId);
3573
3600
  const fields = (0, import_react21.useFields)(tableId);
3574
3601
  const [page, setPage] = (0, import_react20.useState)(0);
3575
- const records = (0, import_react21.useRecords)(tableId, { pageSize, page });
3602
+ const records = (0, import_react21.useRecords)(tableId, { pageSize, page, ...filter ? { filter } : {} });
3576
3603
  const rights = useTableRights(table.data);
3577
3604
  const rows = (0, import_react20.useMemo)(() => records.data?.rows ?? [], [records.data]);
3578
3605
  const rowIds = (0, import_react20.useMemo)(() => rows.map((row) => row.id), [rows]);
@@ -7321,9 +7348,12 @@ function unplacedName(record, field, labels) {
7321
7348
  // src/ViewSwitcher.tsx
7322
7349
  var import_jsx_runtime26 = require("react/jsx-runtime");
7323
7350
  var NO_FIELDS = [];
7324
- function useViewRecords(view, pageSize = 200) {
7351
+ function useViewRecords(view, pageSize = 200, filter) {
7325
7352
  const client = (0, import_react49.useRecordsClient)();
7326
- const table = (0, import_react49.useRecords)(view.ruleId ? null : view.subject, { pageSize });
7353
+ const table = (0, import_react49.useRecords)(view.ruleId ? null : view.subject, {
7354
+ pageSize,
7355
+ ...filter ? { filter } : {}
7356
+ });
7327
7357
  const [ruled, setRuled] = (0, import_react48.useState)({
7328
7358
  rows: [],
7329
7359
  loading: Boolean(view.ruleId),
@@ -7378,6 +7408,7 @@ function ViewSwitcher({
7378
7408
  onNewRecordForm,
7379
7409
  onAskWhoChanged,
7380
7410
  pageSize = 200,
7411
+ filter,
7381
7412
  className
7382
7413
  }) {
7383
7414
  const [layout, setLayout] = (0, import_react48.useState)(view.layout);
@@ -7415,6 +7446,7 @@ function ViewSwitcher({
7415
7446
  {
7416
7447
  tableId: view.subject,
7417
7448
  pageSize,
7449
+ ...filter ? { filter } : {},
7418
7450
  ...local.presentation ?? view.presentation ? { presentation: local.presentation ?? view.presentation } : {},
7419
7451
  ...onViewChange ? { onPresentationChange: (next) => change({ presentation: next }) } : {},
7420
7452
  ...onOpenRecord ? { onOpenRecord } : {},
@@ -7426,6 +7458,7 @@ function ViewSwitcher({
7426
7458
  {
7427
7459
  view: { ...view, ...local, layout },
7428
7460
  pageSize,
7461
+ ...filter ? { filter } : {},
7429
7462
  onChange: change,
7430
7463
  remembered: Boolean(onViewChange),
7431
7464
  ...onOpenRecord ? { onOpenRecord } : {},
@@ -7473,13 +7506,14 @@ function boardColumnChoice(fields, chosenKey, stage) {
7473
7506
  function Board({
7474
7507
  view,
7475
7508
  pageSize,
7509
+ filter,
7476
7510
  onChange,
7477
7511
  remembered,
7478
7512
  onOpenRecord,
7479
7513
  onMoved
7480
7514
  }) {
7481
7515
  const fields = (0, import_react49.useFields)(view.subject);
7482
- const records = useViewRecords(view, pageSize);
7516
+ const records = useViewRecords(view, pageSize, filter);
7483
7517
  const stage = useStageField(view.subject);
7484
7518
  if (fields.error) return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(RefusalNotice, { error: fields.error });
7485
7519
  if (records.error) return /* @__PURE__ */ (0, import_jsx_runtime26.jsx)(RefusalNotice, { error: records.error });
@@ -13000,14 +13034,20 @@ function DashboardCanvas({ tableId, activeDashboardId, filter, className }) {
13000
13034
  rights.admin ? /* @__PURE__ */ (0, import_jsx_runtime47.jsx)(import_design_system44.Button, { size: "sm", variant: "ghost", onClick: () => void remove(board), children: "Delete" }) : null
13001
13035
  ] }) : null
13002
13036
  ] }),
13003
- board && scheduling ? /* @__PURE__ */ (0, import_jsx_runtime47.jsx)(
13004
- DigestScheduler,
13005
- {
13006
- tableId,
13007
- subjectName: board.name,
13008
- ...filter ? { filters: filter } : {},
13009
- onClose: () => setScheduling(false)
13010
- }
13037
+ board && scheduling ? (
13038
+ // ONE SHAPE, SO NO CAST. The dashboard's own filter, the saved view it
13039
+ // schedules over and the drill-through it opens are all the same
13040
+ // `RecordFilter`; the `as Record<string, unknown>` that used to be here
13041
+ // was the seam where they could have drifted.
13042
+ /* @__PURE__ */ (0, import_jsx_runtime47.jsx)(
13043
+ DigestScheduler,
13044
+ {
13045
+ tableId,
13046
+ subjectName: board.name,
13047
+ ...filter ? { filters: filter } : {},
13048
+ onClose: () => setScheduling(false)
13049
+ }
13050
+ )
13011
13051
  ) : null,
13012
13052
  board ? /* @__PURE__ */ (0, import_jsx_runtime47.jsxs)("div", { className: "flex flex-col gap-1", children: [
13013
13053
  /* @__PURE__ */ (0, import_jsx_runtime47.jsxs)("div", { className: "flex items-center gap-1", children: [
@@ -15007,9 +15047,35 @@ var import_react111 = require("react");
15007
15047
  var import_react112 = require("@ai-matrx/records/react");
15008
15048
  var import_design_system54 = require("@ai-matrx/design-system");
15009
15049
  var import_jsx_runtime58 = require("react/jsx-runtime");
15010
- function cameFromLine(label, groupField) {
15011
- const grouped = groupField ? `grouped into the same columns the chart used` : `for this table`;
15012
- return `You came here from \u201C${label}\u201D. This is every record ${grouped} \u2014 not only the ones that number counted \u2014 so the column you clicked is here with the rest beside it.`;
15050
+ function filterInWords(filter, fields) {
15051
+ const nameOf = (key) => {
15052
+ const field = fields.find((f) => f.key === key);
15053
+ return field ? fieldName(field) : humanize(key);
15054
+ };
15055
+ const parts = Object.keys(filter).map((key) => {
15056
+ const value = filter[key];
15057
+ if (value === null || value === void 0) return `${nameOf(key)} is not filled in`;
15058
+ if (typeof value === "object") {
15059
+ const from = (value.from ?? "").toString().trim();
15060
+ const to = (value.to ?? "").toString().trim();
15061
+ if (from !== "" && to !== "") return `${nameOf(key)} from ${from} up to ${to}`;
15062
+ if (from !== "") return `${nameOf(key)} from ${from} onwards`;
15063
+ if (to !== "") return `${nameOf(key)} up to ${to}`;
15064
+ return `${nameOf(key)} in a period nobody named`;
15065
+ }
15066
+ return `${nameOf(key)} is ${String(value)}`;
15067
+ });
15068
+ if (parts.length === 0) return "";
15069
+ if (parts.length === 1) return parts[0];
15070
+ return `${parts.slice(0, -1).join(", ")} and ${parts[parts.length - 1]}`;
15071
+ }
15072
+ function cameFromLine(label, groupField, question) {
15073
+ const asked = (question ?? "").trim();
15074
+ if (asked === "") {
15075
+ const grouped = groupField ? " grouped into the same columns the chart used" : "";
15076
+ return `You came here from \u201C${label}\u201D. This is every record in this table${grouped} \u2014 that number was counted over all of them.`;
15077
+ }
15078
+ return `You came here from \u201C${label}\u201D. These are only the records that number counted: ${asked}. Everything else in this table is being held back \u2014 clear the filter to see all of it.`;
15013
15079
  }
15014
15080
  var TABLE_NOT_REACHABLE = "This table is not in the organization you are working in, so there is nothing here to show. Switch to the organization that owns it and open it again \u2014 or it may have been deleted.";
15015
15081
  var DEFAULT_VIEW_NAME = "All records";
@@ -15056,11 +15122,14 @@ function TablePage({
15056
15122
  onViewChanged,
15057
15123
  activeGroupField,
15058
15124
  cameFrom,
15125
+ filter,
15059
15126
  className
15060
15127
  }) {
15061
15128
  const client = (0, import_react112.useRecordsClient)();
15062
15129
  const table = (0, import_react112.useTable)(tableId);
15063
15130
  const rights = useTableRights(table.data);
15131
+ const pageFields = (0, import_react112.useFields)(filter ? tableId : null);
15132
+ const askedInWords = filter ? filterInWords(filter, pageFields.data ?? []) : null;
15064
15133
  const organizationId = (0, import_react112.useRecordsClient)().config.organizationId;
15065
15134
  const [view, setView] = (0, import_react111.useState)(null);
15066
15135
  const opening = openingRail(activeRecordId);
@@ -15254,7 +15323,7 @@ function TablePage({
15254
15323
  {
15255
15324
  "data-testid": "came-from-a-number",
15256
15325
  className: "rounded-md border px-3 py-2 text-xs leading-relaxed text-muted-foreground",
15257
- children: cameFromLine(cameFrom, activeGroupField ?? null)
15326
+ children: cameFromLine(cameFrom, activeGroupField ?? null, askedInWords)
15258
15327
  }
15259
15328
  ) : null,
15260
15329
  /* @__PURE__ */ (0, import_jsx_runtime58.jsx)(
@@ -15268,6 +15337,7 @@ function TablePage({
15268
15337
  ...activeGroupField ? { groupField: activeGroupField } : {}
15269
15338
  },
15270
15339
  pageSize,
15340
+ ...filter ? { filter } : {},
15271
15341
  onLayoutChange: (layout) => {
15272
15342
  setLayoutFromLink(layout);
15273
15343
  onViewChanged?.(layout);