@ai-matrx/records-ui 0.60.0 → 0.61.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,43 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.61.0
4
+
5
+ **A link to a board opens a board, and a cell that is still resolving says what
6
+ it is waiting for.** (lane FRONT-DOOR; VERIFIER-8 HIGH-2 and MEDIUM-1.)
7
+
8
+ * **`?view=` was read by nobody.**
9
+ `/data-v2/<table>?view=kanban` returned 200 and rendered the grid — 40 rows,
10
+ grid headers — and nothing on the screen said the view had been dropped. So a
11
+ bookmarked board was a grid and the link an office manager sent every morning
12
+ had never once opened the board. `TablePage` takes `activeView` (the raw
13
+ `?view=` string) and `onViewChanged`, and the five views — grid, kanban,
14
+ calendar, gallery, dashboards — all deep-link. The ADDRESS OUTRANKS the saved
15
+ view's layout until the person picks another, and then it FOLLOWS them: half a
16
+ deep link is a link that works when you arrive and lies when you copy it out of
17
+ the bar afterwards. `pageViewFromParam` also takes the words people actually
18
+ type (`board`, `cards`, `table`, `dashboard`), and a `?view=` this build has no
19
+ view for is SAID (`unknownViewLine`) rather than silently turned into a grid.
20
+ New: `PAGE_VIEWS`, `PAGE_VIEW_LABEL`, `pageViewFromParam`, `unknownViewLine`,
21
+ `openingView`, type `PageView`.
22
+ * **The relation resolver's per-cell fetch is batched, and it was latent.**
23
+ Measured on the real Rincon Plumbing Jobs grid on 2026-09-21: rows at 2,184 ms,
24
+ the CUSTOMER column resolved at 3,017 ms to real names, about 100 ms of
25
+ "Loading…" and ZERO `record_read` calls — so the 10-13 second column VERIFIER-8
26
+ recorded on 0.57 is already gone. What was still there was the shape behind it:
27
+ the chase for an id the bulk read did not cover fired per cell and committed its
28
+ own `setNames`, so forty uncovered cells cost forty round trips AND forty
29
+ re-renders of the whole grid. Ids now gather and the page asks ONCE A TICK, six
30
+ lanes at a time, with ONE commit for every name it learned.
31
+ * **A cell still resolving after one second says what it went to read.**
32
+ "Loading…" is honest for a moment and a lie with a spinner on it after that.
33
+ New: `useLabelWait`, `SAY_WHAT_YOU_ARE_WAITING_FOR_AFTER_MS`; `scalarText` and
34
+ `renderValue` take the sentence as a last argument (optional — every existing
35
+ call site is unchanged).
36
+
37
+ **Consumer action:** a host route that serves a table page should read `?view=`
38
+ and pass `activeView` + `onViewChanged` to `TablePage`, or its view links stay
39
+ dead. `matrx-frontend`'s `/data-v2/[tableId]` does this as of the same session.
40
+
3
41
  ## 0.59.0
4
42
 
5
43
  **Three things the browser walks found by using the builders on real businesses.**
package/dist/index.cjs CHANGED
@@ -110,6 +110,8 @@ __export(src_exports, {
110
110
  NO_SHARE_REASON: () => NO_SHARE_REASON,
111
111
  NO_UPLOAD_REASON: () => NO_UPLOAD_REASON,
112
112
  NotifyRuleEditor: () => NotifyRuleEditor,
113
+ PAGE_VIEWS: () => PAGE_VIEWS,
114
+ PAGE_VIEW_LABEL: () => PAGE_VIEW_LABEL,
113
115
  PARITY_LABEL: () => PARITY_LABEL,
114
116
  PARITY_MADE_OF: () => PARITY_MADE_OF,
115
117
  Peek: () => Peek,
@@ -133,6 +135,7 @@ __export(src_exports, {
133
135
  RefusalNotice: () => RefusalNotice,
134
136
  RelationPicker: () => RelationPicker,
135
137
  SAVED_VIEWS_UNAVAILABLE: () => SAVED_VIEWS_UNAVAILABLE,
138
+ SAY_WHAT_YOU_ARE_WAITING_FOR_AFTER_MS: () => SAY_WHAT_YOU_ARE_WAITING_FOR_AFTER_MS,
136
139
  SERIES_COLORS: () => SERIES_COLORS,
137
140
  SOMEBODY: () => SOMEBODY,
138
141
  STORE_ANSWERS_THESE: () => STORE_ANSWERS_THESE,
@@ -203,6 +206,9 @@ __export(src_exports, {
203
206
  machineIdentityIn: () => machineIdentityIn,
204
207
  memberName: () => memberName,
205
208
  nextInWords: () => nextInWords,
209
+ openingRail: () => openingRail,
210
+ openingView: () => openingView,
211
+ pageViewFromParam: () => pageViewFromParam,
206
212
  parityTypesWithNoExplanation: () => parityTypesWithNoExplanation,
207
213
  parseGridPresentation: () => parseGridPresentation,
208
214
  personActor: () => personActor,
@@ -228,10 +234,12 @@ __export(src_exports, {
228
234
  tableName: () => tableName,
229
235
  tableRightsAt: () => tableRightsAt,
230
236
  tokenFor: () => tokenFor,
237
+ unknownViewLine: () => unknownViewLine,
231
238
  useCanShare: () => useCanShare,
232
239
  useEmbedHandshake: () => useEmbedHandshake,
233
240
  useEnrichCells: () => useEnrichCells,
234
241
  useGridEditing: () => useGridEditing,
242
+ useLabelWait: () => useLabelWait,
235
243
  useMeasuredWidth: () => useMeasuredWidth,
236
244
  useMyChecklistSteps: () => useMyChecklistSteps,
237
245
  useRecordLabels: () => useRecordLabels,
@@ -1399,8 +1407,13 @@ var LabelContext = (0, import_react11.createContext)({
1399
1407
  // provides one, so this is only ever a bare unit test.
1400
1408
  lookup: () => null,
1401
1409
  want: () => {
1402
- }
1410
+ },
1411
+ waiting: () => null
1403
1412
  });
1413
+ var SAY_WHAT_YOU_ARE_WAITING_FOR_AFTER_MS = 1e3;
1414
+ function waitingLineFor(field, target) {
1415
+ return field.type === "list" ? "Reading this column\u2019s list of choices\u2026" : `Reading the ${target ?? "records"} this column points at\u2026`;
1416
+ }
1404
1417
  function idsOf(value) {
1405
1418
  if (Array.isArray(value)) return value.map(String).filter(isId);
1406
1419
  if (typeof value === "string" && isId(value)) return [value];
@@ -1417,13 +1430,48 @@ function RecordLabelProvider({ children }) {
1417
1430
  const client = (0, import_react12.useRecordsClient)();
1418
1431
  const [names, setNames] = (0, import_react11.useState)({});
1419
1432
  const [settled, setSettled] = (0, import_react11.useState)({});
1433
+ const [waitingLines, setWaitingLines] = (0, import_react11.useState)({});
1420
1434
  const asked = (0, import_react11.useRef)(/* @__PURE__ */ new Set());
1421
1435
  const chased = (0, import_react11.useRef)(/* @__PURE__ */ new Set());
1436
+ const timers = (0, import_react11.useRef)(/* @__PURE__ */ new Map());
1437
+ const startWaitClock = (0, import_react11.useCallback)((field, target) => {
1438
+ if (timers.current.has(field.id)) return;
1439
+ timers.current.set(
1440
+ field.id,
1441
+ setTimeout(() => {
1442
+ timers.current.delete(field.id);
1443
+ setWaitingLines(
1444
+ (current) => current[field.id] ? current : { ...current, [field.id]: waitingLineFor(field, target) }
1445
+ );
1446
+ }, SAY_WHAT_YOU_ARE_WAITING_FOR_AFTER_MS)
1447
+ );
1448
+ }, []);
1449
+ const stopWaitClock = (0, import_react11.useCallback)((fieldId) => {
1450
+ const timer = timers.current.get(fieldId);
1451
+ if (timer) {
1452
+ clearTimeout(timer);
1453
+ timers.current.delete(fieldId);
1454
+ }
1455
+ setWaitingLines((current) => {
1456
+ if (!current[fieldId]) return current;
1457
+ const next = { ...current };
1458
+ delete next[fieldId];
1459
+ return next;
1460
+ });
1461
+ }, []);
1462
+ (0, import_react11.useEffect)(
1463
+ () => () => {
1464
+ for (const timer of timers.current.values()) clearTimeout(timer);
1465
+ timers.current.clear();
1466
+ },
1467
+ []
1468
+ );
1422
1469
  const want = (0, import_react11.useCallback)(
1423
1470
  (field) => {
1424
1471
  if (!pointsAtRecords(field)) return;
1425
1472
  if (asked.current.has(field.id)) return;
1426
1473
  asked.current.add(field.id);
1474
+ startWaitClock(field, null);
1427
1475
  void (async () => {
1428
1476
  if (field.type === "list") {
1429
1477
  const result = await client.fieldOptions({ field_id: field.id });
@@ -1437,11 +1485,13 @@ function RecordLabelProvider({ children }) {
1437
1485
  });
1438
1486
  }
1439
1487
  setSettled((current) => ({ ...current, [field.id]: true }));
1488
+ stopWaitClock(field.id);
1440
1489
  return;
1441
1490
  }
1442
1491
  const target = field.relation_target;
1443
1492
  if (!target) {
1444
1493
  setSettled((current) => ({ ...current, [field.id]: true }));
1494
+ stopWaitClock(field.id);
1445
1495
  return;
1446
1496
  }
1447
1497
  const [rows, tables] = await Promise.all([
@@ -1459,24 +1509,54 @@ function RecordLabelProvider({ children }) {
1459
1509
  });
1460
1510
  }
1461
1511
  setSettled((current) => ({ ...current, [field.id]: true }));
1512
+ stopWaitClock(field.id);
1462
1513
  })();
1463
1514
  },
1464
- [client]
1515
+ [client, startWaitClock, stopWaitClock]
1465
1516
  );
1517
+ const queued = (0, import_react11.useRef)(/* @__PURE__ */ new Map());
1518
+ const flushing = (0, import_react11.useRef)(null);
1519
+ const flush = (0, import_react11.useCallback)(() => {
1520
+ flushing.current = null;
1521
+ const batch = [...queued.current.entries()];
1522
+ queued.current.clear();
1523
+ if (batch.length === 0) return;
1524
+ void (async () => {
1525
+ const learned = {};
1526
+ const LANES = 6;
1527
+ let at = 0;
1528
+ await Promise.all(
1529
+ Array.from({ length: Math.min(LANES, batch.length) }, async () => {
1530
+ for (; ; ) {
1531
+ const next = batch[at];
1532
+ at += 1;
1533
+ if (!next) return;
1534
+ const [id] = next;
1535
+ const result = await client.recordRead({ record_id: id });
1536
+ learned[id] = result.ok ? recordName(result.data.document, null, "Untitled") : WITHHELD_RECORD_LABEL;
1537
+ }
1538
+ })
1539
+ );
1540
+ setNames((current) => ({ ...current, ...learned }));
1541
+ for (const [, field] of batch) stopWaitClock(field.id);
1542
+ })();
1543
+ }, [client, stopWaitClock]);
1466
1544
  const chase = (0, import_react11.useCallback)(
1467
1545
  (field, id) => {
1468
1546
  const key = `${field.id}:${id}`;
1469
1547
  if (chased.current.has(key)) return;
1470
1548
  chased.current.add(key);
1471
- void (async () => {
1472
- const result = await client.recordRead({ record_id: id });
1473
- setNames((current) => ({
1474
- ...current,
1475
- [id]: result.ok ? recordName(result.data.document, null, "Untitled") : WITHHELD_RECORD_LABEL
1476
- }));
1477
- })();
1549
+ queued.current.set(id, field);
1550
+ startWaitClock(field, null);
1551
+ if (flushing.current === null) flushing.current = setTimeout(flush, 0);
1478
1552
  },
1479
- [client]
1553
+ [flush, startWaitClock]
1554
+ );
1555
+ (0, import_react11.useEffect)(
1556
+ () => () => {
1557
+ if (flushing.current !== null) clearTimeout(flushing.current);
1558
+ },
1559
+ []
1480
1560
  );
1481
1561
  const lookup = (0, import_react11.useCallback)(
1482
1562
  (field, id) => {
@@ -1487,7 +1567,8 @@ function RecordLabelProvider({ children }) {
1487
1567
  },
1488
1568
  [names, settled, chase]
1489
1569
  );
1490
- const value = (0, import_react11.useMemo)(() => ({ lookup, want }), [lookup, want]);
1570
+ const waiting = (0, import_react11.useCallback)((field) => waitingLines[field.id] ?? null, [waitingLines]);
1571
+ const value = (0, import_react11.useMemo)(() => ({ lookup, want, waiting }), [lookup, want, waiting]);
1491
1572
  return /* @__PURE__ */ (0, import_jsx_runtime7.jsx)(LabelContext.Provider, { value, children });
1492
1573
  }
1493
1574
  function useRecordLabels(fields) {
@@ -1497,6 +1578,10 @@ function useRecordLabels(fields) {
1497
1578
  }, [fields, want]);
1498
1579
  return lookup;
1499
1580
  }
1581
+ function useLabelWait(field) {
1582
+ const { waiting } = (0, import_react11.useContext)(LabelContext);
1583
+ return field ? waiting(field) : null;
1584
+ }
1500
1585
 
1501
1586
  // src/values.tsx
1502
1587
  var import_jsx_runtime8 = require("react/jsx-runtime");
@@ -1510,7 +1595,7 @@ var ABSENCE_WORDS = {
1510
1595
  refused: "refused",
1511
1596
  conflicting: "conflicting"
1512
1597
  };
1513
- function renderValue(field, value, document2, labels) {
1598
+ function renderValue(field, value, document2, labels, stillWaitingFor) {
1514
1599
  const envelope = envelopeFor(document2, field.key);
1515
1600
  if (envelope?.absent) {
1516
1601
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: "text-xs italic text-muted-foreground", title: `VAL-2: ${envelope.absent}`, children: ABSENCE_WORDS[String(envelope.absent)] ?? String(envelope.absent) });
@@ -1531,13 +1616,13 @@ function renderValue(field, value, document2, labels) {
1531
1616
  if (typeof value === "boolean") {
1532
1617
  return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: kind === "checkbox" ? "text-sm" : "", title: value ? "Yes" : "No", children: value ? "\u2713" : "\u2014" });
1533
1618
  }
1534
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: (0, import_design_system5.cn)("truncate", kind === "number" || kind === "currency" || kind === "percent" ? "tabular-nums" : ""), children: scalarText(field, value, labels) });
1619
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { className: (0, import_design_system5.cn)("truncate", kind === "number" || kind === "currency" || kind === "percent" ? "tabular-nums" : ""), children: scalarText(field, value, labels, stillWaitingFor) });
1535
1620
  }
1536
- function scalarText(field, value, labels) {
1621
+ function scalarText(field, value, labels, stillWaitingFor) {
1537
1622
  if (value === null || value === void 0) return "\u2014";
1538
1623
  const kind = editorKindFor(field);
1539
1624
  if (isId(value) && (field.type === "list" || field.type === "relation")) {
1540
- return labels?.(field, String(value)) ?? "Loading\u2026";
1625
+ return labels?.(field, String(value)) ?? stillWaitingFor ?? "Loading\u2026";
1541
1626
  }
1542
1627
  if (typeof value === "number") {
1543
1628
  if (kind === "percent") return `${value}%`;
@@ -1569,9 +1654,10 @@ function RecordValue({
1569
1654
  format
1570
1655
  }) {
1571
1656
  const labels = useRecordLabels(ONE_FIELD_ARRAY_CACHE(field));
1657
+ const stillWaitingFor = useLabelWait(field);
1572
1658
  const formatted = format ? (0, import_data_table.formatColumnValue)({ format }, value) : null;
1573
1659
  if (formatted) return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_data_table.FormattedCell, { result: formatted });
1574
- return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_jsx_runtime8.Fragment, { children: renderValue(field, value, document2, labels) });
1660
+ return /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(import_jsx_runtime8.Fragment, { children: renderValue(field, value, document2, labels, stillWaitingFor) });
1575
1661
  }
1576
1662
  var ONE_FIELD_ARRAYS = /* @__PURE__ */ new WeakMap();
1577
1663
  function ONE_FIELD_ARRAY_CACHE(field) {
@@ -6380,6 +6466,37 @@ var LAYOUT_FIELD_KIND = {
6380
6466
  calendar: "date",
6381
6467
  gallery: null
6382
6468
  };
6469
+ var PAGE_VIEWS = [...VIEW_LAYOUTS, "dashboards"];
6470
+ var PAGE_VIEW_LABEL = {
6471
+ ...LAYOUT_LABEL,
6472
+ dashboards: "Dashboards"
6473
+ };
6474
+ var PAGE_VIEW_SPELLINGS = {
6475
+ grid: "grid",
6476
+ table: "grid",
6477
+ rows: "grid",
6478
+ list: "grid",
6479
+ kanban: "kanban",
6480
+ board: "kanban",
6481
+ pipeline: "kanban",
6482
+ calendar: "calendar",
6483
+ month: "calendar",
6484
+ schedule: "calendar",
6485
+ gallery: "gallery",
6486
+ cards: "gallery",
6487
+ dashboards: "dashboards",
6488
+ dashboard: "dashboards",
6489
+ charts: "dashboards"
6490
+ };
6491
+ function pageViewFromParam(raw) {
6492
+ if (typeof raw !== "string") return null;
6493
+ const token = raw.trim().toLowerCase();
6494
+ if (token === "") return null;
6495
+ return PAGE_VIEW_SPELLINGS[token] ?? null;
6496
+ }
6497
+ function unknownViewLine(raw) {
6498
+ return `The link asked for a view called \u201C${raw}\u201D, which this table has no view for, so you are looking at ${PAGE_VIEW_LABEL.grid}. The views this table has are ${PAGE_VIEWS.map((v) => PAGE_VIEW_LABEL[v]).join(", ")} \u2014 pick one above and the address follows it.`;
6499
+ }
6383
6500
  var LAYOUT_NO_FIELD = {
6384
6501
  grid: "",
6385
6502
  kanban: "This table has no choice field yet, so there is nothing to make columns from. Add a field that holds one of a set of choices \u2014 Add field, then Select \u2014 and it appears here.",
@@ -14517,6 +14634,19 @@ function surfaceChosen(current, asking) {
14517
14634
  function openingRail(activeRecordId) {
14518
14635
  return activeRecordId ? { rail: "record", record: activeRecordId } : { rail: "none", record: null };
14519
14636
  }
14637
+ function openingView(activeView, activeDashboardId) {
14638
+ const named = pageViewFromParam(activeView);
14639
+ if (named === null) {
14640
+ const asked = typeof activeView === "string" ? activeView.trim() : "";
14641
+ return {
14642
+ main: activeDashboardId ? "dashboards" : "records",
14643
+ layout: null,
14644
+ unknown: asked === "" ? null : asked
14645
+ };
14646
+ }
14647
+ if (named === "dashboards") return { main: "dashboards", layout: null, unknown: null };
14648
+ return { main: "records", layout: named, unknown: null };
14649
+ }
14520
14650
  function TablePage({
14521
14651
  tableId,
14522
14652
  pageSize = 100,
@@ -14525,6 +14655,8 @@ function TablePage({
14525
14655
  leaveLabel = "Back to your tables",
14526
14656
  activeDashboardId,
14527
14657
  activeRecordId,
14658
+ activeView,
14659
+ onViewChanged,
14528
14660
  className
14529
14661
  }) {
14530
14662
  const client = (0, import_react110.useRecordsClient)();
@@ -14533,22 +14665,37 @@ function TablePage({
14533
14665
  const organizationId = (0, import_react110.useRecordsClient)().config.organizationId;
14534
14666
  const [view, setView] = (0, import_react109.useState)(null);
14535
14667
  const opening = openingRail(activeRecordId);
14668
+ const opened = openingView(activeView, activeDashboardId);
14536
14669
  const [asking, setAsking] = (0, import_react109.useState)(null);
14670
+ const [layoutFromLink, setLayoutFromLink] = (0, import_react109.useState)(opened.layout);
14537
14671
  const [surface, setSurface] = (0, import_react109.useState)({
14538
- main: activeDashboardId ? "dashboards" : "records",
14672
+ main: opened.main,
14539
14673
  rail: opening.rail
14540
14674
  });
14541
14675
  const { main, rail } = surface;
14542
14676
  const setRail = (next) => setSurface((now) => ({ ...now, rail: next }));
14543
14677
  const [openRecord, setOpenRecord] = (0, import_react109.useState)(opening.record);
14544
14678
  const viewVersion = useRecordVersion(view?.id ?? null);
14545
- const press = (pressed) => setSurface((now) => chooseSurface(now, pressed));
14679
+ const shownLayout = layoutFromLink ?? view?.layout ?? "grid";
14680
+ const press = (pressed) => {
14681
+ const next = chooseSurface(surface, pressed);
14682
+ setSurface(next);
14683
+ if (next.main !== surface.main) {
14684
+ onViewChanged?.(next.main === "dashboards" ? "dashboards" : shownLayout);
14685
+ }
14686
+ };
14546
14687
  const show = (next) => press({ rail: next });
14547
14688
  (0, import_react109.useEffect)(() => {
14548
14689
  if (!activeRecordId) return;
14549
14690
  setOpenRecord(activeRecordId);
14550
14691
  setSurface((now) => ({ ...now, rail: "record" }));
14551
14692
  }, [activeRecordId]);
14693
+ (0, import_react109.useEffect)(() => {
14694
+ const named = pageViewFromParam(activeView);
14695
+ if (named === null) return;
14696
+ setLayoutFromLink(named === "dashboards" ? null : named);
14697
+ setSurface((now) => ({ ...now, main: named === "dashboards" ? "dashboards" : "records" }));
14698
+ }, [activeView]);
14552
14699
  const patchView = (0, import_react109.useCallback)(
14553
14700
  async (patch) => {
14554
14701
  const current = view;
@@ -14702,11 +14849,16 @@ function TablePage({
14702
14849
  /* @__PURE__ */ (0, import_jsx_runtime57.jsx)(ExportMenu, { tableId })
14703
14850
  ] }),
14704
14851
  main === "dashboards" ? /* @__PURE__ */ (0, import_jsx_runtime57.jsx)(DashboardCanvas, { tableId, activeDashboardId: activeDashboardId ?? null }) : /* @__PURE__ */ (0, import_jsx_runtime57.jsxs)(import_jsx_runtime57.Fragment, { children: [
14852
+ opened.unknown ? /* @__PURE__ */ (0, import_jsx_runtime57.jsx)("p", { className: "rounded-md border border-amber-600/40 bg-amber-500/5 px-3 py-2 text-xs leading-relaxed text-amber-700 dark:border-amber-400/40 dark:text-amber-300", children: unknownViewLine(opened.unknown) }) : null,
14705
14853
  /* @__PURE__ */ (0, import_jsx_runtime57.jsx)(
14706
14854
  ViewSwitcher,
14707
14855
  {
14708
- view: view ?? defaultView(tableId),
14856
+ view: { ...view ?? defaultView(tableId), layout: shownLayout },
14709
14857
  pageSize,
14858
+ onLayoutChange: (layout) => {
14859
+ setLayoutFromLink(layout);
14860
+ onViewChanged?.(layout);
14861
+ },
14710
14862
  ...view ? { onViewChange: (patch) => void patchView(patch) } : {},
14711
14863
  ...rights.write ? { onNewRecordForm: () => setRail("new-record") } : {},
14712
14864
  onOpenRecord: (recordId) => {