blixify-ui-web-mcp 0.1.50 → 0.1.53

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.
@@ -427,7 +427,7 @@ see the Write permission matrix below.
427
427
  edit={{
428
428
  allEditable: true,
429
429
  listEditable: true, // inline cell editing in table view
430
- listAddable: true, // inline row creation in table view
430
+ listAddable: true, // an empty row at the bottom of the table view to type new records into
431
431
  deleteBtn: true,
432
432
  }}
433
433
  organise={{ handleNavigate }}
@@ -1114,7 +1114,7 @@ Which field types can be edited from the list table, and how. Types not listed u
1114
1114
  | `enableComments` | `boolean` | Enables create/update/delete comments |
1115
1115
  | `commentCollectionId` | `string` | Collection ID for comments |
1116
1116
  | `commentQuery` | `Query[]` | Additional queries to filter comments |
1117
- | `enableLogs` | `boolean` | Displays create/update/delete logs in list and read views |
1117
+ | `enableLogs` | `boolean` | Displays create/update/delete logs in list and read views as one row per save (time, author, then the field label + type icon from the DTW schema with before › after, or "Updated N fields" with the field names when one save changed several). Clicking a row opens a popover with every before › after in full; a delete is one "Deleted" row whose popover lists the deleted values. Person and listPerson values show the member's name |
1118
1118
  | `logsCollectionId` | `string` | Collection ID for logs |
1119
1119
  | `logQuery` | `Query[]` | Additional queries to filter logs |
1120
1120
  | `userAPIEndpoint` | `string` | URL returning `{ data: { name: string, ...rest } }` — used to resolve user names in comments/logs |
@@ -1147,7 +1147,7 @@ Which field types can be edited from the list table, and how. Types not listed u
1147
1147
  | `groupRead` | `"groupId"` | Groups and separates read view sections by DataStructure `groupId` |
1148
1148
  | `showCollapsibleTab` | `boolean` | Renders tabs as collapsible sections |
1149
1149
  | `showBaseUpdatedAt` | `boolean` | Shows the `baseUpdatedAt` timestamp in Table, Grid, List, and Kanban views |
1150
- | `showGroupByFeature` | `boolean` | Shows the group-by selector in the toolbar |
1150
+ | `showGroupByFeature` | `boolean` | Shows the group-by selector in the toolbar's filter row and as a Group section in the view settings panel, so a list on flex filters or compact mode can be grouped too. Options come from `renderGroupByOptions`, else the reference label options. |
1151
1151
  | `showReadFloatingNav` | `boolean` | Shows a floating section navigator in read view |
1152
1152
  | `showExport` | `boolean` | Shows import/export options (default `true`) |
1153
1153
  | `disableEllipsisOnLongWord` | `boolean` | Wraps long text instead of truncating with ellipsis |
@@ -1156,14 +1156,15 @@ Which field types can be edited from the list table, and how. Types not listed u
1156
1156
  | `addColumnEnabled` | `boolean` | Shows an add-column button in the table header |
1157
1157
  | `persistViewState` | `"persistent" \| "session"` | Remembers the list's sort, filters, hidden columns, page, rows per page, search and reading position per collection, so a Back navigation lands where the user left. `"persistent"` uses `localStorage`; `"session"` writes `sessionStorage` and reads it first, falling back to `localStorage`. The reading position is restored on mount and on returning from a record, not on a tab switch; with several lists on one page, only the list a record was last opened from restores (tracked in `blixify-dtw-persist-last-drill`). Covers table, list, grid, kanban and feeds; not map. List view only |
1158
1158
  | `persistKey` | `string` | Keeps a separate saved view state for this instance: the storage key becomes `blixify-dtw-persist-<collectionId>-<persistKey>`. Saved state is read once, on mount, so give the component a React `key` that changes with `persistKey` |
1159
- | `savedViews` | `boolean` | Shows a row of view tags under the tab bar: save the current filters, flex filters, sort, rows per page and columns under a name, reapply it, update it or delete it. Views belong to the tab they were saved on and are kept in `localStorage` under `blixify-dtw-views-<collectionId>[-<persistKey>][-<currentUser.userEmail>]`. Sort is held by the template while it is on, as with `persistViewState`, and applying a view calls `onChangeSort`, `onChangePagination(0)` and `bareListHandleFilter` when those change. Each write re-reads storage first, so views saved in another browser tab are kept. With `onChangeHide` / `onChangeDrag`, a view's hides and column order stay on screen only: a toggle or reorder after applying one writes back the schema's columns plus that single change |
1159
+ | `savedViews` | `boolean` | Shows a row of view tags under the tab bar: save the current filters, flex filters, sort, rows per page, grouping and columns under a name, reapply it, update it or delete it. Views belong to the tab they were saved on and are kept in `localStorage` under `blixify-dtw-views-<collectionId>[-<persistKey>][-<currentUser.userEmail>]`. Sort is held by the template while it is on, as with `persistViewState`, and applying a view calls `onChangeSort`, `onChangeGroupBy`, `onChangePagination(0)` and `bareListHandleFilter` when those change. Each write re-reads storage first, so views saved in another browser tab are kept. With `onChangeHide` / `onChangeDrag`, a view's hides and column order stay on screen only: a toggle or reorder after applying one writes back the schema's columns plus that single change |
1160
1160
  | `defaultSavedViews` | `DefaultSavedView[]` | Views provided by the app (`{ id?, name, tabId?, state }`), listed before the user's own and never deleted. Without `tabId` a view is listed on every tab. A field left out of `state` is reset to its default when the view is applied. `id` must be unique; without one the view gets `default-<index>`, its position in the whole array |
1161
1161
  | `sharedViews` | `SharedSavedView[]` | Views the app stores for everyone (`{ id, name, tabId, state }`), e.g. on the record the list belongs to. Given, it replaces this browser's storage: the list shows only these, saving goes to `onChangeSharedViews`, and nothing is read from or written to `localStorage` |
1162
1162
  | `onChangeSharedViews` | `(views: SharedSavedView[]) => void` | Called with the whole list after a view is saved, updated or deleted, for the app to store and pass back as `sharedViews`; a second change before it comes back builds on the list just sent. The whole list is replaced each time, so two editors saving at once is last-writer-wins. Without it the app's views are read-only and “+ Save view” is hidden |
1163
1163
  | `defaultSavedViewId` | `string` | The view everyone opens on, marked ★, stored by the app. The list opens on it when no `initialSavedViewId` is given — also when it arrives after mount — unless `persistViewState` restored a state or a view was already picked. A view that no longer exists leaves the list on All. Opening on it does not call `onChangeSort` |
1164
1164
  | `onChangeDefaultSavedViewId` | `(id: string \| null) => void` | Gives each view a ☆ that makes it the default; the ★ on the default takes it off (`null`, the list opens on All). Deleting the default view also sends `null` |
1165
1165
  | `initialSavedViewId` | `string` | A view the list opens on, once, in place of the ★ default — e.g. a page embedding a datasheet opens it on a chosen view. A view that no longer exists, or is on another tab, leaves the list on All. Opening on it does not call `onChangeSort` |
1166
- | `groupBy` | `string` | Column ID to group list results by |
1166
+ | `groupBy` | `string` | Column ID to group list results by. With `bareNewDTW` the groups are bands in one table. Grouped by a `list` column, every option plus "Unknown/No Value" is a band with its real total that reads its own rows under the list's filters, search and sort: in scroll mode each band loads more as it scrolls, in page mode each band has its own pager and the list-wide pager is hidden. Grouped by any other column, page mode groups the rows of the current page and scroll mode keeps the normal scroll. A search, filter, sort or regroup starts every band over. Firebase and `bareListMobileView` keep the normal pagination. Saved views store and restore it |
1167
+ | `onChangeGroupBy` | `(groupBy: string) => void` | Called when the user picks another grouping in the group-by selector or by applying a saved view (`""` for none), so the app can store it with the tab. Not called when the list opens on a view |
1167
1168
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
1168
1169
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
1169
1170
  | `unionCollections` | `any[]` | Additional collections to union-query alongside the main collection |
@@ -5,6 +5,7 @@ import { ActivityModal } from "./activityModal";
5
5
  import { DataStructure, DevSettings } from "./model";
6
6
  import { DataTemplateWrapper } from "../dataWrapper";
7
7
  import { ActivityLog } from "./dataStateModel";
8
+ import { Modal } from "../../display/modal";
8
9
  import { handleApplyStoryQueries } from "../dataWrapper/storyBackend.fixtures";
9
10
 
10
11
  const meta = { title: "Data/Activity Panel" };
@@ -35,15 +36,34 @@ const taskSchema: DataStructure[] = [
35
36
  },
36
37
  { id: "owner", name: "Owner", type: "person" },
37
38
  ];
39
+ // INFO: the standalone panel gets the full schema so every log row resolves a
40
+ // label and type icon; the DTW story keeps a narrow table and shows the raw-id fallback
41
+ const activitySchema: DataStructure[] = [
42
+ ...taskSchema,
43
+ {
44
+ id: "priority",
45
+ name: "Priority",
46
+ type: "list",
47
+ listSelection: ["Normal", "High"],
48
+ listSelectionColour: ["blue", "orange"],
49
+ },
50
+ { id: "notes", name: "Notes", type: "textArea" },
51
+ { id: "dueDate", name: "Due Date", type: "date" },
52
+ { id: "contactEmail", name: "Contact Email", type: "email" },
53
+ { id: "contactPhone", name: "Contact Phone", type: "contact" },
54
+ { id: "driveLink", name: "Project Drive", type: "string" },
55
+ ];
38
56
 
39
57
  const ActivityDemo = ({
40
58
  darkMode = false,
41
59
  logsOnly = false,
42
60
  integrated = false,
61
+ inModal = false,
43
62
  }: {
44
63
  darkMode?: boolean;
45
64
  logsOnly?: boolean;
46
65
  integrated?: boolean;
66
+ inModal?: boolean;
47
67
  }) => {
48
68
  const [ready, setReady] = useState(false);
49
69
  const [failPings, setFailPings] = useState(false);
@@ -123,6 +143,26 @@ const ActivityDemo = ({
123
143
  },
124
144
  ],
125
145
  },
146
+ {
147
+ _id: "log-3",
148
+ collectionId: "tasks",
149
+ itemId: "task-1",
150
+ userId: "sam",
151
+ type: "update",
152
+ createdAt: "2026-09-28T08:15:00Z",
153
+ values: [
154
+ {
155
+ columnId: "contactEmail",
156
+ oldValue: "",
157
+ newValue: "sam.lee@example.com",
158
+ },
159
+ { columnId: "contactPhone", newValue: "0123456789" },
160
+ {
161
+ columnId: "driveLink",
162
+ newValue: "https://drive.example.com/folders/review-activity",
163
+ },
164
+ ],
165
+ },
126
166
  {
127
167
  _id: "log-2",
128
168
  collectionId: "tasks",
@@ -188,6 +228,20 @@ const ActivityDemo = ({
188
228
  setRecipients(event.mentionedUserIds.join(", "));
189
229
  },
190
230
  };
231
+
232
+ const renderPanel = () => (
233
+ <ActivityModal
234
+ title="Tasks"
235
+ collectionId="tasks"
236
+ dataId="task-1"
237
+ devSettings={settings}
238
+ darkMode={darkMode}
239
+ activityLog={activityLog}
240
+ model={activitySchema}
241
+ initialTab={inModal ? "logs" : undefined}
242
+ />
243
+ );
244
+
191
245
  return (
192
246
  <div
193
247
  className={`mx-auto max-w-3xl rounded-2xl p-3 sm:p-6 ${darkMode ? "bg-gray-900 text-gray-100" : "bg-gray-50 text-gray-800"}`}
@@ -200,9 +254,10 @@ const ActivityDemo = ({
200
254
  {integrated && (
201
255
  <p className="mb-4 text-sm text-gray-500">
202
256
  Click the chat icon beside a task for its comments and audit history,
203
- or open the task and click its chat bubble. The list clock opens logs first. Tasks have separate conversations. All
204
- data and notifications are simulated; refreshing resets the demo.
205
- Audit entries are seeded examples.
257
+ or open the task and click its chat bubble. The list clock opens logs
258
+ first. Tasks have separate conversations. All data and notifications
259
+ are simulated; refreshing resets the demo. Audit entries are seeded
260
+ examples.
206
261
  </p>
207
262
  )}
208
263
  {!logsOnly && (
@@ -247,15 +302,19 @@ const ActivityDemo = ({
247
302
  setSelectedId(id ?? "");
248
303
  }}
249
304
  />
250
- ) : (
251
- <ActivityModal
252
- title="Tasks"
253
- collectionId="tasks"
254
- dataId="task-1"
255
- devSettings={settings}
305
+ ) : inModal ? (
306
+ // INFO: mirrors how DTW mounts the panel, so row popovers are checked
307
+ // against the Modal's clipping and outside-click behaviour
308
+ <Modal
309
+ open
310
+ title="Activity Log"
311
+ maxWidth="4xl"
256
312
  darkMode={darkMode}
257
- activityLog={activityLog}
313
+ onClose={() => {}}
314
+ renderContent={renderPanel}
258
315
  />
316
+ ) : (
317
+ renderPanel()
259
318
  ))}
260
319
  </div>
261
320
  );
@@ -265,3 +324,4 @@ export const CommentsAndLogs = () => <ActivityDemo />;
265
324
  export const DarkMode = () => <ActivityDemo darkMode />;
266
325
  export const LogsOnly = () => <ActivityDemo logsOnly />;
267
326
  export const WithDataTemplateWrapper = () => <ActivityDemo integrated />;
327
+ export const InModal = () => <ActivityDemo inModal />;
@@ -1024,3 +1024,245 @@ export const RelatedRecords = () => (
1024
1024
  <RelatedRecordsDemo />
1025
1025
  </div>
1026
1026
  );
1027
+
1028
+ // INFO: Monday-board per-group scroll pagination demo. A single collection with
1029
+ // thousands of rows spread across a handful of owners — grouping by owner then
1030
+ // pages EACH band independently as you scroll inside it, instead of bulk
1031
+ // loading every grouped row at once. All local mock data, no backend.
1032
+ const GROUPBY_COLLECTION = "demo-groupby-items";
1033
+
1034
+ // INFO: six owners with matching Tag-palette colours so each band is tinted;
1035
+ // a status list column is offered as a second groupable column.
1036
+ const GROUPBY_OWNERS = ["Alice", "Bob", "Carol", "Dan", "Eve", "Frank"];
1037
+ const GROUPBY_OWNER_COLOURS = [
1038
+ "red",
1039
+ "blue",
1040
+ "green",
1041
+ "yellow",
1042
+ "indigo",
1043
+ "purple",
1044
+ ];
1045
+ const GROUPBY_STATUSES = ["Draft", "In Review", "Approved"];
1046
+
1047
+ const GROUPBY_SCHEMA: DataStructure[] = [
1048
+ { id: "name", name: "Item", type: "string" },
1049
+ {
1050
+ id: "owner",
1051
+ name: "Owner",
1052
+ type: "list",
1053
+ listSelection: GROUPBY_OWNERS,
1054
+ listSelectionColour: GROUPBY_OWNER_COLOURS,
1055
+ },
1056
+ {
1057
+ id: "status",
1058
+ name: "Status",
1059
+ type: "list",
1060
+ listSelection: GROUPBY_STATUSES,
1061
+ },
1062
+ { id: "createdAt", name: "Created At", type: "date" },
1063
+ ];
1064
+
1065
+ // INFO: 2400 rows in owner blocks (~400 each), so the first page holds only
1066
+ // Alice — every other band has to load on its own. Every 40th row has no owner
1067
+ // and lands in "Unknown/No Value".
1068
+ const renderGroupByRecords = () => {
1069
+ const rows: any[] = [];
1070
+ const total = 2400;
1071
+ for (let index = 0; index < total; index++) {
1072
+ const owner =
1073
+ index % 40 === 39
1074
+ ? ""
1075
+ : GROUPBY_OWNERS[Math.floor(index / (total / GROUPBY_OWNERS.length))];
1076
+ const status = GROUPBY_STATUSES[index % GROUPBY_STATUSES.length];
1077
+ rows.push({
1078
+ _id: `gb-${index}`,
1079
+ name: `${owner || "No owner"} item #${index}`,
1080
+ owner,
1081
+ status,
1082
+ createdAt: new Date(2026, 0, 1 + (index % 180)),
1083
+ });
1084
+ }
1085
+ return rows;
1086
+ };
1087
+
1088
+ /**
1089
+ * INFO: the mock honours cursor/limit AND the active group filter so each group
1090
+ * pages independently. The list request body carries a
1091
+ * { type:"=", queryId:"owner"/"status", value } entry when a group is expanded —
1092
+ * the mock filters the store to that value, then slices by cursor/limit, so
1093
+ * `count` is that group's total and the band knows when it is exhausted. With no
1094
+ * group filter present it falls back to the full store (the initial grouped
1095
+ * read that seeds the first page of every band). delayResponse makes the
1096
+ * per-group spinner watchable as rows stream in.
1097
+ */
1098
+ const handleInstallGroupByMock = (store: any[]) => {
1099
+ const mock = new MockAdapter(axios, {
1100
+ onNoMatch: "passthrough",
1101
+ delayResponse: 500,
1102
+ });
1103
+ const renderUrl = (type: string) =>
1104
+ `${DEMO_ENDPOINT}/${GROUPBY_COLLECTION}/${type}`;
1105
+ const handleParseBody = (config: any) => {
1106
+ try {
1107
+ return JSON.parse(config?.data ?? "{}");
1108
+ } catch (err) {
1109
+ return {};
1110
+ }
1111
+ };
1112
+
1113
+ // INFO: applies every "=" and "!in" condition — a band's own query and
1114
+ // any filter the list carries — so a filter narrows each band's total
1115
+ const handleMatchQuery = (row: any, query: any[]) =>
1116
+ (Array.isArray(query) ? query : []).every((eachQuery) => {
1117
+ if (!eachQuery?.queryId) return true;
1118
+ const value = row[eachQuery.queryId];
1119
+ if (eachQuery.type === "=") return value === eachQuery.value;
1120
+ if (eachQuery.type === "!in") return !eachQuery.value.includes(value);
1121
+ return true;
1122
+ });
1123
+
1124
+ mock.onPost(renderUrl("list")).reply((config) => {
1125
+ const body = handleParseBody(config);
1126
+ // INFO: e2e hook — a band read for this value fails, counted per attempt
1127
+ const failValue = (window as any).__groupByFailValue;
1128
+ if (
1129
+ failValue &&
1130
+ (body.query ?? []).some(
1131
+ (eachQuery: any) =>
1132
+ eachQuery?.type === "=" && eachQuery.value === failValue,
1133
+ )
1134
+ ) {
1135
+ (window as any).__groupByFailCount =
1136
+ ((window as any).__groupByFailCount ?? 0) + 1;
1137
+ return [500, { message: "failed" }];
1138
+ }
1139
+ const cursor = typeof body.cursor === "number" ? body.cursor : 0;
1140
+ // INFO: no limit means every row, as the list endpoint does for a
1141
+ // grouped page-mode read
1142
+ const limit =
1143
+ typeof body.limit === "number" ? body.limit : Number.MAX_SAFE_INTEGER;
1144
+ const rows = store.filter((eachRow) =>
1145
+ handleMatchQuery(eachRow, body.query),
1146
+ );
1147
+ return [
1148
+ 200,
1149
+ { data: rows.slice(cursor, cursor + limit), count: rows.length },
1150
+ ];
1151
+ });
1152
+
1153
+ mock.onPost(renderUrl("get")).reply((config) => {
1154
+ const body = handleParseBody(config);
1155
+ return [200, { data: store.find((each) => each._id === body.id) ?? null }];
1156
+ });
1157
+
1158
+ mock
1159
+ .onPost(`${DEMO_ENDPOINT}/spaces/list`)
1160
+ .reply(200, { data: [], count: 0 });
1161
+
1162
+ return mock;
1163
+ };
1164
+
1165
+ const GroupByScrollPaginationDemo = (props: {
1166
+ paginationType: "scroll" | "page";
1167
+ }) => {
1168
+ const store = React.useMemo(() => renderGroupByRecords(), []);
1169
+ React.useMemo(() => handleInstallGroupByMock(store), [store]);
1170
+
1171
+ const [typeParam, setTypeParam] = useState<"list" | "read" | "update">(
1172
+ "list",
1173
+ );
1174
+ const [dataId, setDataId] = useState<string>("");
1175
+ const [viewId, setViewId] = useState<string | undefined>(undefined);
1176
+
1177
+ return (
1178
+ <div className="w-full">
1179
+ <DataTemplateImpl
1180
+ title="Group by scroll pagination"
1181
+ description="2,400 items across six owners — each group pages on its own as you scroll"
1182
+ type={typeParam}
1183
+ id={dataId}
1184
+ collectionId={GROUPBY_COLLECTION}
1185
+ model={GROUPBY_SCHEMA}
1186
+ localURLEndpoint=""
1187
+ devSettings={demoDevSettings}
1188
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
1189
+ limit={25}
1190
+ bareSettings={{ bareNewDTW: true, bareFlexFilters: true }}
1191
+ organise={{
1192
+ viewId,
1193
+ showGroupByFeature: true,
1194
+ groupBy: "owner",
1195
+ // INFO: a saved view regroups by status; All goes back to owner
1196
+ savedViews: true,
1197
+ defaultSavedViews: [
1198
+ {
1199
+ id: "by-status",
1200
+ name: "By status",
1201
+ state: { groupBy: "status" },
1202
+ },
1203
+ {
1204
+ id: "draft-only",
1205
+ name: "Draft only",
1206
+ state: {
1207
+ flexFilters: [
1208
+ {
1209
+ id: "f1",
1210
+ columnId: "status",
1211
+ operator: "=",
1212
+ value: "Draft",
1213
+ },
1214
+ ],
1215
+ },
1216
+ },
1217
+ ],
1218
+ paginationType: props.paginationType,
1219
+ // INFO: the group Select consumes Option[] (key/label) — offer the two
1220
+ // list columns that make sense to group a board on.
1221
+ renderGroupByOptions: () => [
1222
+ { key: "owner", label: "Owner" },
1223
+ { key: "status", label: "Status" },
1224
+ ],
1225
+ handleNavigate: (type: string, id?: string, view?: string) => {
1226
+ setTypeParam(type as any);
1227
+ setDataId(id ?? "");
1228
+ if (view) setViewId(view);
1229
+ },
1230
+ }}
1231
+ />
1232
+ </div>
1233
+ );
1234
+ };
1235
+
1236
+ export const GroupByScrollPagination = () => (
1237
+ <div className="w-full">
1238
+ <div className="font-semibold mb-1 text-gray-900">
1239
+ Data Template — Monday-board per-group scroll pagination
1240
+ </div>
1241
+ <div className="text-sm mb-4 text-gray-600">
1242
+ 2,400 items in owner blocks (~400 each, every 40th with no owner), all
1243
+ from local mock data. The board opens grouped by <code>Owner</code>; the
1244
+ first page holds only Alice, yet every owner and Unknown/No Value is a
1245
+ band from the start and loads its own rows. To demo: keep the{" "}
1246
+ <strong>Group</strong> picker on Owner (or switch it to Status), then
1247
+ scroll down inside a band — that group fetches its next page on its own,
1248
+ with a short spinner, while the other groups stay where they are. Each
1249
+ group pages independently against the mock, Monday-board style, rather
1250
+ than bulk-loading every row.
1251
+ </div>
1252
+ <GroupByScrollPaginationDemo paginationType="scroll" />
1253
+ </div>
1254
+ );
1255
+
1256
+ export const GroupByPagePagination = () => (
1257
+ <div className="w-full">
1258
+ <div className="font-semibold mb-1 text-gray-900">
1259
+ Data Template — group by with page pagination
1260
+ </div>
1261
+ <div className="text-sm mb-4 text-gray-600">
1262
+ The same 2,400 items in page mode. Each page reads 25 rows and groups only
1263
+ those rows, so the pager moves through the list without skipping or
1264
+ repeating rows.
1265
+ </div>
1266
+ <GroupByScrollPaginationDemo paginationType="page" />
1267
+ </div>
1268
+ );
@@ -173,3 +173,31 @@ unconditionally hands every viewer a delete button.
173
173
  The older `organise.addColumnEnabled` path is unaffected: it still opens the
174
174
  formula builder and gets no bin, since those tables never gained a plain-column
175
175
  `+` and so have no way to add a deleted column back.
176
+
177
+ ## Adding rows
178
+
179
+ With `addable={{ active: true, onAddData }}` the table keeps an empty row at the bottom, as a spreadsheet does:
180
+
181
+ - Clicking any of its cells starts a new row with that cell focused.
182
+ - Enter saves the row through `onAddData` and opens the next one, focused on its first cell. A multi-line field keeps Enter for a new line.
183
+ - Escape drops the row. Clicking another row of the table saves it.
184
+ - A required field left empty keeps the row open with the field marked. If the click went to another row, the row closes and what was typed comes back the next time it is opened.
185
+ - Text, number, phone, email, long text, yes/no, date, single and multi select, person, people, role, Gantt and canvas columns are filled in the row (`supportedNewTableAddList`). Any other column, and a disabled one, stays empty in the new row and never stops a row being added — it is filled in afterwards. A required column of such a type is not checked when the row is saved, so the record starts without it.
186
+ - If `onAddData` rejects, the typed values come back in the row, unless the next row is already being typed in. DataTemplate rejects when a create fails, after its own error message.
187
+ - The row is hidden while rows are selected and in the mobile view.
188
+
189
+ ## Editing cells
190
+
191
+ With `editable.active` most columns edit in the cell (`supportedInlineEditList`). Image, images, address, file and files open the same form field in a modal (`supportedModalEditList`) and save through `onUpdateData` like any cell. Markdown, signature, address list and password are edited on the record, not in the table. A disabled column stays read-only.
192
+
193
+ ## Groups
194
+
195
+ `groups` draws rows as coloured, collapsible bands inside the one table. DataTemplate builds them from `organise.groupBy`; a consumer using NewTable on its own passes them directly.
196
+
197
+ | Prop | Type | Behavior |
198
+ | -------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
199
+ | `groups` | `{ id, title, colour?, rowIds, count? }[]` | One band per entry; `rowIds` reference row ids. `count` is the band's real total, shown in its header instead of the rows it holds |
200
+ | `onGroupLoadMore` | `(groupId: string) => void` | Scroll paging: fired when the bottom of a band scrolls into view, so that band loads its next rows |
201
+ | `onGroupPageChange` | `(groupId: string, page: number) => void` | Page paging: each band gets the list footer (numbered pages, chevrons, go-to-page) under its rows, scoped to that band. 0-based page |
202
+ | `groupLoadingStatus` | `{ [groupId]: { loadingMore?, exhausted?, page?, size?, fetched? } }` | Per band: a spinner while a page loads; `exhausted` removes the scroll sentinel; `page` and `size` drive the band pager once `fetched` |
203
+
@@ -155,6 +155,70 @@ const matrixColumns: NewTableColumn[] = [
155
155
  { key: "run", title: "" },
156
156
  ];
157
157
 
158
+ // INFO: rows added like a spreadsheet — the empty bottom row takes typing in
159
+ // any cell, Enter saves and opens the next row. Photo and Contract have no
160
+ // inline editor: they stay empty in a new row without blocking it, and an
161
+ // existing row's cell opens its editor in a modal
162
+ const spreadsheetColumns: NewTableColumn[] = [
163
+ { key: "name", title: "Name", type: "string" },
164
+ { key: "qty", title: "Qty", type: "number", optional: true },
165
+ {
166
+ key: "stage",
167
+ title: "Stage",
168
+ type: "list",
169
+ listSelection: ["Lead", "Quoted", "Won"],
170
+ optional: true,
171
+ },
172
+ { key: "dueDate", title: "Due", type: "date", optional: true },
173
+ { key: "photo", title: "Photo", type: "image" },
174
+ { key: "contract", title: "Contract", type: "file" },
175
+ ];
176
+
177
+ const SpreadsheetRows = () => {
178
+ const [rows, setRows] = useState<NewTableRow[]>([
179
+ { id: "row-1", name: "Rooftop survey", qty: 2, stage: "Lead" },
180
+ ]);
181
+ const [saved, setSaved] = useState<any[]>([]);
182
+ return (
183
+ <>
184
+ <NewTableImpl
185
+ columns={spreadsheetColumns}
186
+ data={rows}
187
+ paginationType="page"
188
+ size={rows.length}
189
+ limit={20}
190
+ pageIndex={0}
191
+ handleUpdatePageIndex={() => {}}
192
+ editable={{
193
+ active: true,
194
+ onUpdateData: async (rowId, columnId, value) => {
195
+ setRows((previous) =>
196
+ previous.map((eachRow) =>
197
+ eachRow.id === rowId
198
+ ? { ...eachRow, [columnId]: value?.[columnId] ?? value }
199
+ : eachRow,
200
+ ),
201
+ );
202
+ },
203
+ }}
204
+ addable={{
205
+ active: true,
206
+ onAddData: (added) => {
207
+ setSaved((previous) => [...previous, added]);
208
+ setRows((previous) => [
209
+ ...previous,
210
+ { id: `row-${previous.length + 1}`, ...added },
211
+ ]);
212
+ },
213
+ }}
214
+ />
215
+ <pre data-testid="spreadsheet-saved" className="mt-3 text-xs">
216
+ {JSON.stringify(saved)}
217
+ </pre>
218
+ </>
219
+ );
220
+ };
221
+
158
222
  // INFO: inline edit and add-column together — the + appends to the same columns
159
223
  // the cells are editing, so a new field is immediately fillable
160
224
  const EditableWithAddColumn = () => {
@@ -386,7 +450,7 @@ const ElementCellsMatrix = () => {
386
450
  setRows((prev) => prev.filter((row) => row.id !== rowId)),
387
451
  }}
388
452
  enableCopy={false}
389
- emptyTitle="No rows yet — use Create New Item"
453
+ emptyTitle="No rows yet"
390
454
  />
391
455
  </div>
392
456
  );
@@ -611,6 +675,13 @@ export const NewTable = () => {
611
675
  <EditableWithAddColumn />
612
676
  </StorySection>
613
677
 
678
+ <StorySection
679
+ title="Adding rows like a spreadsheet"
680
+ description="addable.active=true keeps an empty row at the bottom. Click any of its cells and type; Enter saves the row and opens the next one, Escape drops it, and clicking another row saves it. A column the row cannot fill in (Photo, Contract) stays empty without blocking it; on a saved row its cell opens the editor in a modal."
681
+ >
682
+ <SpreadsheetRows />
683
+ </StorySection>
684
+
614
685
  <StorySection
615
686
  title="Editable with disabled columns"
616
687
  description="A column marked disabled in the schema stays read-only in an editable table, the same as in the form. Hover Value or Notes to edit; Unit, Source and the coloured Status column never open an editor, and a row click still reaches the row."
@@ -620,7 +691,7 @@ export const NewTable = () => {
620
691
 
621
692
  <StorySection
622
693
  title="Element cells with row actions (config matrix)"
623
- description="Cells can be React elements: kit inputs with cellInput={{ active: true }} (the same compact mode the DTW inline edit matrix uses) and IconButtons per row, while the parent owns the row state. Typed columns are what make the inline 'Create New Item' row render inputs — element-only columns render nothing there. Do NOT combine with editable.active: edit mode wraps element cells in its own editors (from 1.2.66 a React element cell passes through, but its inputs are ~286px wide). Wrap in overflow-x-auto when the columns outgrow the pane."
694
+ description="Cells can be React elements: kit inputs with cellInput={{ active: true }} (the same compact mode the DTW inline edit matrix uses) and IconButtons per row, while the parent owns the row state. Typed columns are what make the empty add row render inputs — element-only columns render nothing there. Do NOT combine with editable.active: edit mode wraps element cells in its own editors (from 1.2.66 a React element cell passes through, but its inputs are ~286px wide). Wrap in overflow-x-auto when the columns outgrow the pane."
624
695
  >
625
696
  <ElementCellsMatrix />
626
697
  </StorySection>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.50",
3
+ "version": "0.1.53",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {