@ai-matrx/records-ui 0.28.0 → 0.30.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,74 @@
1
1
  # Changelog — @ai-matrx/records-ui
2
2
 
3
+ ## 0.30.0
4
+
5
+ **The notification editor stops asking for the notifier's own reader.**
6
+
7
+ `NotifyRuleEditor` listed subscriptions with `client.aggSubscriptions()` and read
8
+ its cadences from `client.aggSubscriptionCadences()`. Both are `custom.agg_*` —
9
+ the NOTIFIER's internals — and neither holds a client grant, so a signed-in
10
+ person opened this panel on *"permission denied for function agg_subscriptions"*
11
+ and the cadence picker silently fell back to a word typed into the file. Lane
12
+ TEST-BENCH photographed that refusal on the try-everything page on 2026-09-20.
13
+
14
+ It now asks the person's own doors: `client.subscriptions({ table_id })`, which
15
+ answers what is addressed to them plus — only where they hold admin on this
16
+ Table — anyone's over it, narrowed to Tables they can already open; and
17
+ `client.subscriptionCadences()`, which delegates to the notifier's one array.
18
+
19
+ Two consequences, both visible: the list is now scoped to the Table the editor is
20
+ about (it used to answer the whole organization), and a **muted** subscription
21
+ appears with the sentence *"Switched off — it tells nobody until somebody
22
+ switches it back on."* The notifier's reader excluded muted rows, so switching one
23
+ off used to make it vanish from this list and read as gone rather than as off.
24
+
25
+ **Consumer action:** requires `@ai-matrx/records` 0.17.0 or above.
26
+
27
+ ## 0.29.0
28
+
29
+ **A saved view can now say how the table is SECTIONED, how wide its columns are,
30
+ and how tall its rows are — and the grid windows itself and stops implying it
31
+ counted everything.**
32
+
33
+ The shared data table gained grouping, controlled column widths, virtualization
34
+ and an honest count in design-system 0.23.0. This is the store's half, and it
35
+ rides the document a view already has (`records_ui_view.presentation`): no new
36
+ column, no new table, and an agent sets any of it with the same record write it
37
+ already uses for colors and formats.
38
+
39
+ - **Sections by a Field.** `presentation.grouping` names a Field key, with
40
+ optional per-Field subtotals, a section order and the sections to keep shut.
41
+ Grouped by Status, the table draws one collapsible header per status with a
42
+ count of records on it — and a view that names a Field but no subtotals gets a
43
+ COUNT, never a total nobody asked for.
44
+ - **Remembered column widths.** `presentation.widths` is sparse and keyed by
45
+ Field key, so a view saved before widths existed still reads. Drag a column
46
+ wider and the width is kept.
47
+ - **A row height.** `presentation.rowHeight`, clamped to 24–96px. A view asking
48
+ for anything outside that is dropped rather than obeyed, because a saved look
49
+ must not be able to make a table unreadable.
50
+ - **The grid windows itself.** Virtualization is on for every record table; the
51
+ primitive's own threshold decides when it engages, so short tables are
52
+ untouched and a very long one scrolls.
53
+ - **The count says what it covers.** The search and filters in the grid's
54
+ toolbar run in the browser over the page the read door returned, so the table
55
+ now states that in plain English instead of letting the loaded count pose as
56
+ the whole table — and says the total is unknown when the door cannot give one.
57
+ - **The person's own changes are saved back onto the view.** Picking a column to
58
+ group by, shutting a section and resizing a column travel `Grid`'s new
59
+ `onPresentationChange` into the door every other view change already uses, so
60
+ `TablePage` writes them onto the view record with the version it read. A host
61
+ that binds no door still gets all three for the visit.
62
+
63
+ ### Consumer action
64
+
65
+ - Nothing to change. Every new field is optional and a view that carries none of
66
+ them renders exactly as it did.
67
+ - A host that mounts `Grid` directly and wants these remembered binds
68
+ `onPresentationChange` and saves the look onto its view record;
69
+ `ViewSwitcher` and `TablePage` already do.
70
+ - Needs `@ai-matrx/design-system` 0.23.0 or above.
71
+
3
72
  ## 0.28.0
4
73
 
5
74
  **The publish canary installs every peer this package declares.** It named
@@ -12,6 +81,8 @@ installed in a pass of their own so each brings its own peers (`react-is` is
12
81
  `recharts`'). 0.27.0 was tagged and never published; its tag was left where it
13
82
  is.
14
83
 
84
+ ## 0.27.0
85
+
15
86
  **A first-time admin could not make a table, and the gate could not see it.**
16
87
 
17
88
  Creating one showed a red refusal and made a table anyway; that table opened with
package/dist/index.cjs CHANGED
@@ -81,6 +81,8 @@ __export(src_exports, {
81
81
  LAYOUT_NO_FIELD: () => LAYOUT_NO_FIELD,
82
82
  LEVEL_WORD: () => LEVEL_WORD,
83
83
  MACHINE_IDENTITY: () => MACHINE_IDENTITY,
84
+ MAX_ROW_HEIGHT: () => MAX_ROW_HEIGHT,
85
+ MIN_ROW_HEIGHT: () => MIN_ROW_HEIGHT,
84
86
  NOT_ANSWERED_YET: () => NOT_ANSWERED_YET,
85
87
  NO_CHAT_REASON: () => NO_CHAT_REASON,
86
88
  NO_COLUMNS_WHY: () => NO_COLUMNS_WHY,
@@ -1874,6 +1876,27 @@ var import_data_table3 = require("@ai-matrx/design-system/data-table");
1874
1876
  var import_data_table2 = require("@ai-matrx/design-system/data-table");
1875
1877
  var EMPTY_PRESENTATION = {};
1876
1878
  var FROZEN_COLUMN_WIDTH = 200;
1879
+ var MIN_ROW_HEIGHT = 24;
1880
+ var MAX_ROW_HEIGHT = 96;
1881
+ var AGGREGATE_KINDS = [
1882
+ "count",
1883
+ "sum",
1884
+ "average",
1885
+ "min",
1886
+ "max",
1887
+ "empty",
1888
+ "filled",
1889
+ "unique",
1890
+ "checked",
1891
+ "unchecked",
1892
+ "percentFilled"
1893
+ ];
1894
+ var GROUP_ORDERS = [
1895
+ "value-asc",
1896
+ "value-desc",
1897
+ "count-desc",
1898
+ "first-seen"
1899
+ ];
1877
1900
  function isRecord(v) {
1878
1901
  return typeof v === "object" && v !== null && !Array.isArray(v);
1879
1902
  }
@@ -1910,10 +1933,51 @@ function parseGridPresentation(raw) {
1910
1933
  const frozen = value["frozen"].filter((k) => typeof k === "string");
1911
1934
  if (frozen.length > 0) out.frozen = frozen;
1912
1935
  }
1936
+ const grouping = parseGrouping(value["grouping"]);
1937
+ if (grouping) out.grouping = grouping;
1938
+ if (isRecord(value["widths"])) {
1939
+ const widths = {};
1940
+ for (const [key, raw2] of Object.entries(value["widths"])) {
1941
+ if (typeof raw2 !== "number" || !Number.isFinite(raw2) || raw2 <= 0) continue;
1942
+ widths[key] = Math.round(raw2);
1943
+ }
1944
+ if (Object.keys(widths).length > 0) out.widths = widths;
1945
+ }
1946
+ const rowHeight = value["rowHeight"];
1947
+ if (typeof rowHeight === "number" && Number.isFinite(rowHeight) && rowHeight >= MIN_ROW_HEIGHT && rowHeight <= MAX_ROW_HEIGHT) {
1948
+ out.rowHeight = Math.round(rowHeight);
1949
+ }
1950
+ return out;
1951
+ }
1952
+ function parseGrouping(raw) {
1953
+ if (!isRecord(raw)) return void 0;
1954
+ const field = raw["field"];
1955
+ if (typeof field !== "string" || field.trim() === "") return void 0;
1956
+ const out = { field };
1957
+ if (isRecord(raw["aggregates"])) {
1958
+ const aggregates = {};
1959
+ for (const [key, spec] of Object.entries(raw["aggregates"])) {
1960
+ if (!isRecord(spec)) continue;
1961
+ const kind = spec["kind"];
1962
+ if (typeof kind !== "string") continue;
1963
+ if (!AGGREGATE_KINDS.includes(kind)) continue;
1964
+ const label = spec["label"];
1965
+ aggregates[key] = typeof label === "string" ? { kind, label } : { kind };
1966
+ }
1967
+ if (Object.keys(aggregates).length > 0) out.aggregates = aggregates;
1968
+ }
1969
+ const order = raw["order"];
1970
+ if (typeof order === "string" && GROUP_ORDERS.includes(order)) {
1971
+ out.order = order;
1972
+ }
1973
+ if (Array.isArray(raw["collapsed"])) {
1974
+ const collapsed = raw["collapsed"].filter((k) => typeof k === "string");
1975
+ if (collapsed.length > 0) out.collapsed = collapsed;
1976
+ }
1913
1977
  return out;
1914
1978
  }
1915
1979
  function presentationIsEmpty(p) {
1916
- return (p.style === void 0 || (0, import_data_table2.tableStyleIsEmpty)(p.style)) && Object.keys(p.formats ?? {}).length === 0 && (p.frozen?.length ?? 0) === 0;
1980
+ return (p.style === void 0 || (0, import_data_table2.tableStyleIsEmpty)(p.style)) && Object.keys(p.formats ?? {}).length === 0 && (p.frozen?.length ?? 0) === 0 && p.grouping === void 0 && Object.keys(p.widths ?? {}).length === 0 && p.rowHeight === void 0;
1917
1981
  }
1918
1982
  function presentationDocument(p) {
1919
1983
  return presentationIsEmpty(p) ? "" : JSON.stringify(p);
@@ -1984,6 +2048,7 @@ function Grid({
1984
2048
  toolbarActions,
1985
2049
  editable = true,
1986
2050
  presentation = EMPTY_PRESENTATION,
2051
+ onPresentationChange,
1987
2052
  className
1988
2053
  }) {
1989
2054
  const host = useRecordsUi();
@@ -2035,6 +2100,67 @@ function Grid({
2035
2100
  ),
2036
2101
  [ordered, canWrite, editing, mayWriteRow, whyNotRow, presentation.formats, frozenKeys]
2037
2102
  );
2103
+ const columnIds = (0, import_react13.useMemo)(() => ordered.map((field) => field.key), [ordered]);
2104
+ const [localColumns, setLocalColumns] = (0, import_react13.useState)(
2105
+ null
2106
+ );
2107
+ const [localWidths, setLocalWidths] = (0, import_react13.useState)(null);
2108
+ const writeLook = (0, import_react13.useCallback)(
2109
+ (next) => onPresentationChange?.(next),
2110
+ [onPresentationChange]
2111
+ );
2112
+ const widths = localWidths ?? presentation.widths;
2113
+ const columnState = (0, import_react13.useMemo)(
2114
+ () => ({
2115
+ order: localColumns?.order ?? columnIds,
2116
+ hidden: localColumns?.hidden ?? [],
2117
+ onChange: (next) => setLocalColumns(next),
2118
+ ...widths ? { widths } : {},
2119
+ // A drag that changed nothing would be the silent half of a control that
2120
+ // looks alive, so the width always lands somewhere — the session at
2121
+ // least, the view record when a door is bound.
2122
+ onWidthsChange: (next) => {
2123
+ setLocalWidths(next);
2124
+ writeLook({ ...presentation, widths: next });
2125
+ }
2126
+ }),
2127
+ [columnIds, localColumns, widths, presentation, writeLook]
2128
+ );
2129
+ const groupingConfig = (0, import_react13.useMemo)(() => {
2130
+ const view = presentation.grouping;
2131
+ const field = view?.field ?? null;
2132
+ if (field === null && !onPresentationChange) return null;
2133
+ const aggregates = view && Object.keys(view.aggregates ?? {}).length > 0 ? view.aggregates : field ? { [field]: { kind: "count" } } : void 0;
2134
+ return {
2135
+ columnId: field,
2136
+ rowNoun: "record",
2137
+ ...aggregates ? { aggregates } : {},
2138
+ ...view?.order ? { order: view.order } : {},
2139
+ ...view?.collapsed ? { collapsed: view.collapsed } : {},
2140
+ ...onPresentationChange ? {
2141
+ onColumnIdChange: (columnId) => {
2142
+ if (columnId === null) {
2143
+ const { grouping: _dropped, ...rest } = presentation;
2144
+ writeLook(rest);
2145
+ return;
2146
+ }
2147
+ writeLook({
2148
+ ...presentation,
2149
+ grouping: {
2150
+ field: columnId,
2151
+ ...presentation.grouping?.aggregates ? { aggregates: presentation.grouping.aggregates } : {},
2152
+ ...presentation.grouping?.order ? { order: presentation.grouping.order } : {}
2153
+ }
2154
+ });
2155
+ },
2156
+ onCollapsedChange: (collapsed) => {
2157
+ const current = presentation.grouping;
2158
+ if (!current) return;
2159
+ writeLook({ ...presentation, grouping: { ...current, collapsed } });
2160
+ }
2161
+ } : {}
2162
+ };
2163
+ }, [presentation, onPresentationChange, writeLook]);
2038
2164
  if (table.error) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(RefusalNotice, { error: table.error, className });
2039
2165
  if (fields.error) return /* @__PURE__ */ (0, import_jsx_runtime9.jsx)(RefusalNotice, { error: fields.error, className });
2040
2166
  const nothingToShowYet = table.loading || fields.loading || !rights.known || records.loading && rows.length === 0;
@@ -2098,6 +2224,15 @@ function Grid({
2098
2224
  pageSize,
2099
2225
  detail: { enabled: false },
2100
2226
  window: { enabled: false },
2227
+ ...groupingConfig ? { grouping: groupingConfig } : {},
2228
+ columnState,
2229
+ ...presentation.rowHeight ? { rowHeight: presentation.rowHeight } : {},
2230
+ virtualize: true,
2231
+ coverage: {
2232
+ answeredBy: "client",
2233
+ noun: "record",
2234
+ ...typeof records.data?.total === "number" ? { total: records.data.total } : {}
2235
+ },
2101
2236
  ...presentation.style ? {
2102
2237
  tableStyle: {
2103
2238
  style: presentation.style,
@@ -3990,6 +4125,7 @@ function ViewSwitcher({
3990
4125
  tableId: view.subject,
3991
4126
  pageSize,
3992
4127
  ...local.presentation ?? view.presentation ? { presentation: local.presentation ?? view.presentation } : {},
4128
+ ...onViewChange ? { onPresentationChange: (next) => change({ presentation: next }) } : {},
3993
4129
  ...onOpenRecord ? { onOpenRecord } : {},
3994
4130
  ...onNewRecordForm ? { onNewRecordForm } : {}
3995
4131
  }
@@ -6833,10 +6969,13 @@ function NotifyRuleEditor({ tableId, seed, className }) {
6833
6969
  const [busy, setBusy] = (0, import_react64.useState)(false);
6834
6970
  const load = (0, import_react64.useCallback)(async () => {
6835
6971
  const [held, offered] = await Promise.all([
6836
- client.aggSubscriptions(),
6972
+ // THE PERSON'S OWN DOOR, not the notifier's. It answers what is addressed
6973
+ // to them plus — only where they hold admin on this Table — anyone's over
6974
+ // it, and it is narrowed to Tables they can already open.
6975
+ client.subscriptions({ table_id: tableId }),
6837
6976
  // The cadences are ASKED of the store, never a list typed into a picker
6838
6977
  // here that could drift from what the digest runner understands.
6839
- client.aggSubscriptionCadences()
6978
+ client.subscriptionCadences()
6840
6979
  ]);
6841
6980
  if (!held.ok) {
6842
6981
  setError(held.error);
@@ -6847,7 +6986,7 @@ function NotifyRuleEditor({ tableId, seed, className }) {
6847
6986
  if (offered.ok) setCadences(offered.data);
6848
6987
  if (host.savedViews) setViews(await host.savedViews());
6849
6988
  else setViews(null);
6850
- }, [client, host]);
6989
+ }, [client, host, tableId]);
6851
6990
  (0, import_react64.useEffect)(() => {
6852
6991
  void load();
6853
6992
  }, [load]);
@@ -6937,6 +7076,7 @@ function NotifyRuleEditor({ tableId, seed, className }) {
6937
7076
  held.cadence === "digest" && held.schedule ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("span", { className: "text-muted-foreground", children: held.schedule }) : null,
6938
7077
  held.recipient_user_id === null ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("span", { className: "text-destructive", children: "This one has nobody to tell, so it fires at nobody." }) : null,
6939
7078
  held.saved_view_id === null ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("span", { className: "text-destructive", children: "This one names no view, so the store never admits a record to it." }) : null,
7079
+ held.muted ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)("span", { className: "text-muted-foreground", children: "Switched off \u2014 it tells nobody until somebody switches it back on." }) : null,
6940
7080
  rights.write ? /* @__PURE__ */ (0, import_jsx_runtime32.jsx)(
6941
7081
  import_design_system30.Button,
6942
7082
  {