blixify-ui-web-mcp 0.1.72 → 0.1.76

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.
@@ -0,0 +1,334 @@
1
+ import { Menu, Transition } from "@headlessui/react";
2
+ import axios from "axios";
3
+ import MockAdapter from "axios-mock-adapter";
4
+ import Papa from "papaparse";
5
+ import React, { useEffect, useMemo, useState } from "react";
6
+ import { ButtonGroup } from "../../action/buttonGroup";
7
+ import { DataTemplate as DataTemplateImpl } from ".";
8
+ import { handleApplyStoryQueries } from "../dataWrapper/storyBackend.fixtures";
9
+ import { DataStructure } from "./model";
10
+
11
+ const meta = { title: "Data/Data Template" };
12
+ export default meta;
13
+
14
+ // INFO: its own endpoint, so the mock never answers for another story
15
+ const DEMO_ENDPOINT = "https://dtw-read-drawer-demo.local/api";
16
+ const DEMO_COLLECTION = "demo-read-drawer-records";
17
+ const COMMENTS_COLLECTION = `${DEMO_COLLECTION}-comments`;
18
+ const LOGS_COLLECTION = `${DEMO_COLLECTION}-logs`;
19
+ const CURRENT_USER = "alice";
20
+ const STAGES = ["New", "In Progress", "Done"];
21
+ const OWNERS = ["Alice", "Ben", "Chitra", "Dan"];
22
+
23
+ const DEMO_SCHEMA: DataStructure[] = [
24
+ { id: "name", name: "Record", type: "string" },
25
+ { id: "stage", name: "Stage", type: "list", listSelection: STAGES },
26
+ { id: "owner", name: "Owner", type: "string" },
27
+ { id: "summary", name: "Summary", type: "string" },
28
+ ];
29
+
30
+ const DRAWER_TOGGLE = [
31
+ { id: "drawer", name: "Read drawer" },
32
+ { id: "page", name: "No drawer" },
33
+ ];
34
+ const ROLE_TOGGLE = [
35
+ { id: "editor", name: "Can update" },
36
+ { id: "viewer", name: "Viewer" },
37
+ ];
38
+ const COMMENTS_TOGGLE = [
39
+ { id: "on", name: "Comments" },
40
+ { id: "off", name: "No comments" },
41
+ ];
42
+ const LAYOUT_TOGGLE = [
43
+ { id: "desktop", name: "Desktop" },
44
+ { id: "mobile", name: "Mobile" },
45
+ ];
46
+
47
+ const demoDevSettings: any = {
48
+ server: "mongoServer",
49
+ dbEndpoint: DEMO_ENDPOINT,
50
+ dbAPIToken: "",
51
+ dbUserToken: "",
52
+ schemaEndpoint: DEMO_ENDPOINT,
53
+ schemaEndpointToken: "",
54
+ imageEndpoint: "https://placehold.co/600x400/e2e8f0/475569.png?text=",
55
+ uploadEndpoint: "",
56
+ organisationMembers: [
57
+ { key: "alice", label: "Alice Tan" },
58
+ { key: "ben", label: "Ben Wong" },
59
+ ],
60
+ };
61
+
62
+ // INFO: a few records carry comments so the bubble's count shows on some rows
63
+ // and not others
64
+ const COMMENT_COUNTS: { [id: string]: number } = {
65
+ "rec-01": 3,
66
+ "rec-02": 1,
67
+ "rec-04": 12,
68
+ "rec-07": 120,
69
+ };
70
+
71
+ const renderDemoComments = () =>
72
+ Object.entries(COMMENT_COUNTS).flatMap(([itemId, count]) =>
73
+ Array.from({ length: Math.min(count, 5) }, (_, index) => ({
74
+ _id: `${itemId}-comment-${index + 1}`,
75
+ collectionId: DEMO_COLLECTION,
76
+ itemId,
77
+ userId: index % 2 === 0 ? "ben" : "alice",
78
+ comment: `Comment ${index + 1} on ${itemId}`,
79
+ createdAt: new Date(Date.UTC(2026, 10, 1, 9, index)).toISOString(),
80
+ })),
81
+ );
82
+
83
+ const renderDemoRecords = () =>
84
+ Array.from({ length: 12 }, (_, index) => {
85
+ const number = String(index + 1).padStart(2, "0");
86
+ return {
87
+ _id: `rec-${number}`,
88
+ name: `Record ${number}`,
89
+ stage: STAGES[index % STAGES.length],
90
+ owner: OWNERS[index % OWNERS.length],
91
+ summary: `Summary for record ${number}`,
92
+ };
93
+ });
94
+
95
+ const handleInstallDemoMock = (
96
+ store: any[],
97
+ activity: { [collection: string]: any[] },
98
+ ) => {
99
+ const mock = new MockAdapter(axios, {
100
+ onNoMatch: "passthrough",
101
+ delayResponse: 150,
102
+ });
103
+ const renderUrl = (type: string) =>
104
+ `${DEMO_ENDPOINT}/${DEMO_COLLECTION}/${type}`;
105
+ const handleParseBody = (config: any) => {
106
+ try {
107
+ return JSON.parse(config?.data ?? "{}");
108
+ } catch (err) {
109
+ return {};
110
+ }
111
+ };
112
+
113
+ mock
114
+ .onPost(renderUrl("list"))
115
+ .reply(() => [200, { data: store, count: store.length }]);
116
+ mock.onPost(renderUrl("get")).reply((config) => {
117
+ const body = handleParseBody(config);
118
+ return [200, { data: store.find((each) => each._id === body.id) ?? null }];
119
+ });
120
+ mock.onPost(renderUrl("update")).reply((config) => {
121
+ const body = handleParseBody(config);
122
+ const index = store.findIndex((each) => each._id === body.id);
123
+ if (index >= 0) store[index] = { ...store[index], ...body.data };
124
+ return [200, { data: body.id }];
125
+ });
126
+ mock
127
+ .onPost(
128
+ new RegExp(
129
+ `^${DEMO_ENDPOINT}/(${COMMENTS_COLLECTION}|${LOGS_COLLECTION})/[a-z]+$`,
130
+ ),
131
+ )
132
+ .reply((config) => {
133
+ const [collection, action] = (config.url ?? "").split("/").slice(-2);
134
+ const body = handleParseBody(config);
135
+ const rows = activity[collection];
136
+ if (action === "list") {
137
+ const data = handleApplyStoryQueries(rows, body.query).sort(
138
+ (a: any, b: any) =>
139
+ String(b.createdAt ?? "").localeCompare(String(a.createdAt ?? "")),
140
+ );
141
+ const cursor = body.cursor ?? 0;
142
+ return [
143
+ 200,
144
+ {
145
+ data: data.slice(cursor, cursor + (body.limit ?? 10)),
146
+ count: data.length,
147
+ },
148
+ ];
149
+ }
150
+ if (action === "get")
151
+ return [200, { data: rows.find((row) => row._id === body.id) }];
152
+ if (action === "create") rows.push(body.data);
153
+ if (action === "update") {
154
+ const index = rows.findIndex((row) => row._id === body.id);
155
+ if (index >= 0) rows[index] = { ...rows[index], ...body.data };
156
+ }
157
+ if (action === "delete") {
158
+ const index = rows.findIndex((row) => row._id === body.id);
159
+ if (index >= 0) rows.splice(index, 1);
160
+ }
161
+ return [200, { success: true }];
162
+ });
163
+ // INFO: the tab bar waits on this lookup
164
+ mock
165
+ .onPost(`${DEMO_ENDPOINT}/spaces/list`)
166
+ .reply(200, { data: [], count: 0 });
167
+
168
+ return mock;
169
+ };
170
+
171
+ const ReadDrawerDemo = () => {
172
+ const [typeParam, setTypeParam] = useState<"list" | "read" | "update">(
173
+ "list",
174
+ );
175
+ const [dataId, setDataId] = useState<string>("");
176
+ const [drawerMode, setDrawerMode] = useState<"drawer" | "page">("drawer");
177
+ const [role, setRole] = useState<"editor" | "viewer">("editor");
178
+ const [comments, setComments] = useState<"on" | "off">("on");
179
+ const [layout, setLayout] = useState<"desktop" | "mobile">("desktop");
180
+ const [isMockReady, setIsMockReady] = useState(false);
181
+
182
+ const store = useMemo(() => renderDemoRecords(), []);
183
+ const activity = useMemo(
184
+ () => ({
185
+ [COMMENTS_COLLECTION]: renderDemoComments(),
186
+ [LOGS_COLLECTION]: [] as any[],
187
+ }),
188
+ [],
189
+ );
190
+ const hasComments = comments === "on";
191
+ const hasDrawer = drawerMode === "drawer";
192
+ const canUpdate = role === "editor";
193
+ const isMobile = layout === "mobile";
194
+
195
+ // INFO: in an effect so it survives StrictMode's double mount
196
+ useEffect(() => {
197
+ const mock = handleInstallDemoMock(store, activity);
198
+ setIsMockReady(true);
199
+ return () => {
200
+ mock.restore();
201
+ setIsMockReady(false);
202
+ };
203
+ }, [store, activity]);
204
+
205
+ const handleNavigate = (type: string, id?: string) => {
206
+ setTypeParam(type as "list" | "read" | "update");
207
+ setDataId(id ?? "");
208
+ };
209
+
210
+ return (
211
+ <div className="w-full">
212
+ <div className="mb-4 flex flex-wrap items-center gap-3">
213
+ <ButtonGroup
214
+ selection={DRAWER_TOGGLE}
215
+ selectedId={drawerMode}
216
+ variant="tag"
217
+ size="small"
218
+ onClick={(id) => {
219
+ setDrawerMode(id as "drawer" | "page");
220
+ handleNavigate("list");
221
+ }}
222
+ />
223
+ <ButtonGroup
224
+ selection={ROLE_TOGGLE}
225
+ selectedId={role}
226
+ variant="tag"
227
+ size="small"
228
+ onClick={(id) => setRole(id as "editor" | "viewer")}
229
+ />
230
+ <ButtonGroup
231
+ selection={COMMENTS_TOGGLE}
232
+ selectedId={comments}
233
+ variant="tag"
234
+ size="small"
235
+ onClick={(id) => setComments(id as "on" | "off")}
236
+ />
237
+ <ButtonGroup
238
+ selection={LAYOUT_TOGGLE}
239
+ selectedId={layout}
240
+ variant="tag"
241
+ size="small"
242
+ onClick={(id) => setLayout(id as "desktop" | "mobile")}
243
+ />
244
+ {typeParam !== "list" && !hasDrawer && (
245
+ <button
246
+ type="button"
247
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
248
+ onClick={() => handleNavigate("list")}
249
+ >
250
+ ← Back to list
251
+ </button>
252
+ )}
253
+ </div>
254
+ <div className={isMobile ? "mx-auto max-w-sm" : "w-full"}>
255
+ {isMockReady && (
256
+ <DataTemplateImpl
257
+ key={`${drawerMode}-${role}-${comments}-${layout}`}
258
+ title="Read Drawer"
259
+ description="Offline records"
260
+ type={typeParam}
261
+ id={dataId}
262
+ collectionId={DEMO_COLLECTION}
263
+ model={DEMO_SCHEMA}
264
+ localURLEndpoint=""
265
+ devSettings={demoDevSettings}
266
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
267
+ organise={{
268
+ handleNavigate,
269
+ // INFO: counted from the live comments, so a comment added in
270
+ // the panel shows on the row once the list reloads
271
+ onFetchCommentCounts: hasComments
272
+ ? async (ids: string[]) =>
273
+ Object.fromEntries(
274
+ ids.map((id) => [
275
+ id,
276
+ id in COMMENT_COUNTS && COMMENT_COUNTS[id] > 5
277
+ ? COMMENT_COUNTS[id]
278
+ : activity[COMMENTS_COLLECTION].filter(
279
+ (row) => row.itemId === id,
280
+ ).length,
281
+ ]),
282
+ )
283
+ : undefined,
284
+ }}
285
+ edit={{
286
+ allEditable: canUpdate,
287
+ listEditable: canUpdate,
288
+ disabledUpdate: !canUpdate,
289
+ activityLog: hasComments
290
+ ? {
291
+ enableComments: true,
292
+ commentCollectionId: COMMENTS_COLLECTION,
293
+ enableLogs: true,
294
+ logsCollectionId: LOGS_COLLECTION,
295
+ currentUserId: CURRENT_USER,
296
+ }
297
+ : undefined,
298
+ }}
299
+ bareSettings={{
300
+ bareNewDTW: true,
301
+ bareFormDrawer: hasDrawer,
302
+ bareListMobileView: isMobile,
303
+ bareUpdateMobileView: isMobile,
304
+ }}
305
+ viewSettings={
306
+ hasDrawer
307
+ ? { readDrawer: { anchor: "responsive", maxWidth: "3xl" } }
308
+ : undefined
309
+ }
310
+ />
311
+ )}
312
+ </div>
313
+ </div>
314
+ );
315
+ };
316
+
317
+ export const ReadDrawerRowAction = () => (
318
+ <div className="w-full">
319
+ <div className="font-semibold mb-1 text-gray-900">
320
+ Data Template — row action beside the title
321
+ </div>
322
+ <div className="text-sm mb-4 text-gray-600">
323
+ Hover a table row to show the chevron beside its title, which opens the
324
+ record's read view for anyone who can see it — in the drawer with a read
325
+ drawer, as a page with No drawer. Then come two divided columns: the
326
+ pencil, which opens the edit form, only for people who can update, and the
327
+ comment bubble, with a count on rows that have any (12 and 120 show the
328
+ two-digit and 99+ badges). Drag the Record column narrower to see its
329
+ floor. Mobile cards open from the card, so they carry the pencil and the
330
+ bubble but no chevron.
331
+ </div>
332
+ <ReadDrawerDemo />
333
+ </div>
334
+ );
@@ -1368,3 +1368,165 @@ export const GroupByPagePagination = () => (
1368
1368
  <GroupByScrollPaginationDemo paginationType="page" />
1369
1369
  </div>
1370
1370
  );
1371
+
1372
+ const DEFAULTS_COLLECTION = "demo-workspace-defaults";
1373
+ const DEFAULTS_MEMBERS = [
1374
+ { key: "u1", label: "Aisha Rahman" },
1375
+ { key: "u2", label: "Ben Tan" },
1376
+ ];
1377
+ const DEFAULTS_SCHEMA: DataStructure[] = [
1378
+ { id: "name", name: "Name", type: "string" },
1379
+ {
1380
+ id: "stage",
1381
+ name: "Stage",
1382
+ type: "list",
1383
+ listSelection: ["New", "Quoted", "Won"],
1384
+ optional: true,
1385
+ },
1386
+ {
1387
+ id: "status",
1388
+ name: "Status",
1389
+ type: "list",
1390
+ listSelection: ["Open", "Waiting", "Closed"],
1391
+ optional: true,
1392
+ },
1393
+ { id: "owner", name: "Owner", type: "person", optional: true },
1394
+ { id: "amount", name: "Amount", type: "number", optional: true },
1395
+ { id: "due", name: "Due", type: "date", optional: true },
1396
+ ];
1397
+
1398
+ const handleInstallDefaultsMock = (store: any[]) => {
1399
+ const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
1400
+ const renderUrl = (type: string) =>
1401
+ `${DEMO_ENDPOINT}/${DEFAULTS_COLLECTION}/${type}`;
1402
+ const handleParse = (config: any) => JSON.parse(config?.data ?? "{}");
1403
+ const handleMatch = (row: any, query: any[]) =>
1404
+ (query ?? []).every((eachQuery: any) => {
1405
+ const value = row[eachQuery.queryId];
1406
+ const handleIn = (list: any[]) =>
1407
+ list.some((each) => (each === null ? value == null : each === value));
1408
+ if (eachQuery.type === "=") return value === eachQuery.value;
1409
+ if (eachQuery.type === "in") return handleIn(eachQuery.value);
1410
+ if (eachQuery.type === "!in") return !handleIn(eachQuery.value);
1411
+ return true;
1412
+ });
1413
+ mock.onPost(renderUrl("list")).reply((config) => {
1414
+ const body = handleParse(config);
1415
+ const rows = store.filter((each) => handleMatch(each, body.query));
1416
+ // INFO: a grouped sum, in the shape the list endpoint answers one with
1417
+ if (body.aggregate?.type === "sum") {
1418
+ const totals: { [key: string]: any } = {};
1419
+ rows.forEach((each) => {
1420
+ const key = each[body.aggregate.groupId] ?? null;
1421
+ const total = (totals[String(key)] = totals[String(key)] ?? {
1422
+ _id: key,
1423
+ value: 0,
1424
+ });
1425
+ total.value += Number(each[body.aggregate.queryId]) || 0;
1426
+ });
1427
+ return [200, { data: [{ _id: "range", value: Object.values(totals) }] }];
1428
+ }
1429
+ return [200, { data: rows, count: rows.length }];
1430
+ });
1431
+ mock.onPost(renderUrl("create")).reply((config) => {
1432
+ const row = { ...handleParse(config).data, _id: `r${store.length + 1}` };
1433
+ store.push(row);
1434
+ (window as any).__defaultsWrites = [
1435
+ ...((window as any).__defaultsWrites ?? []),
1436
+ { type: "create", data: row },
1437
+ ];
1438
+ return [200, { success: true, data: row }];
1439
+ });
1440
+ mock.onPost(renderUrl("update")).reply((config) => {
1441
+ const body = handleParse(config);
1442
+ const row = store.find((each) => each._id === body.id);
1443
+ if (row) Object.assign(row, body.data);
1444
+ (window as any).__defaultsWrites = [
1445
+ ...((window as any).__defaultsWrites ?? []),
1446
+ { type: "update", id: body.id, data: body.data },
1447
+ ];
1448
+ return [200, { success: true }];
1449
+ });
1450
+ mock
1451
+ .onPost(`${DEMO_ENDPOINT}/spaces/list`)
1452
+ .reply(200, { data: [], count: 0 });
1453
+ return mock;
1454
+ };
1455
+
1456
+ const WorkspaceDefaultsDemo = () => {
1457
+ const store = React.useMemo(
1458
+ () => [
1459
+ {
1460
+ _id: "r1",
1461
+ name: "Rooftop survey",
1462
+ stage: "New",
1463
+ status: "Open",
1464
+ owner: "u1",
1465
+ amount: 1200,
1466
+ due: new Date(Date.now() - 2 * 86400000).toISOString(),
1467
+ },
1468
+ {
1469
+ _id: "r2",
1470
+ name: "Inverter quote",
1471
+ stage: "Quoted",
1472
+ status: "Waiting",
1473
+ owner: "u2",
1474
+ amount: 5400.5,
1475
+ due: new Date().toISOString(),
1476
+ },
1477
+ {
1478
+ _id: "r3",
1479
+ name: "Panel install",
1480
+ stage: "Won",
1481
+ status: "Closed",
1482
+ owner: "u1",
1483
+ amount: 18000,
1484
+ },
1485
+ ],
1486
+ [],
1487
+ );
1488
+ React.useMemo(() => handleInstallDefaultsMock(store), [store]);
1489
+
1490
+ return (
1491
+ <DataTemplateImpl
1492
+ title="Workspace defaults"
1493
+ description=""
1494
+ type="list"
1495
+ collectionId={DEFAULTS_COLLECTION}
1496
+ model={DEFAULTS_SCHEMA}
1497
+ localURLEndpoint=""
1498
+ devSettings={{ ...demoDevSettings, organisationMembers: DEFAULTS_MEMBERS }}
1499
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
1500
+ edit={{
1501
+ allEditable: true,
1502
+ listAddable: true,
1503
+ activityLog: {
1504
+ enableLogs: true,
1505
+ logsCollectionId: `${DEFAULTS_COLLECTION}-logs`,
1506
+ currentUserId: "u1",
1507
+ },
1508
+ }}
1509
+ bareSettings={{ bareNewDTW: true }}
1510
+ organise={{
1511
+ groupBy: "stage",
1512
+ showGroupByFeature: true,
1513
+ dateWarnings: true,
1514
+ datasheetDefaults: true,
1515
+ renderGroupByOptions: () => [
1516
+ { key: "stage", label: "Stage" },
1517
+ { key: "owner", label: "Owner" },
1518
+ ],
1519
+ onFetchCommentCounts: async () => ({ r1: 3 }),
1520
+ }}
1521
+ />
1522
+ );
1523
+ };
1524
+
1525
+ export const WorkspaceDefaults = () => (
1526
+ <div className="w-full">
1527
+ <div className="font-semibold mb-1 text-gray-900">
1528
+ Data Template — workspace defaults
1529
+ </div>
1530
+ <WorkspaceDefaultsDemo />
1531
+ </div>
1532
+ );
@@ -242,14 +242,14 @@ const res = await query.call({
242
242
 
243
243
  ## Query
244
244
 
245
- | Param | Type | Description | Optional |
246
- | ---------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------- | ------------------------ |
247
- | `type` | `search \| = \| != \| > \| < \| >< \| in \| !in \| exists \| elemMatch \| >size \| <size` | Operator | |
248
- | `value` | `any` | Filter value (array for `><` and `in`) | |
249
- | `queryId` | `string` | Field name | Required except `search` |
250
- | `searchIds` | `string[]` | Fields to search across | Required for `search` |
251
- | `orQuery` | `boolean` | Use OR instead of AND | ✓ |
252
- | `orQueryGroupId` | `string` | Group OR conditions with AND between groups | ✓ |
245
+ | Param | Type | Description | Optional |
246
+ | ---------------- | ----------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------ |
247
+ | `type` | `search \| = \| != \| > \| < \| >< \| in \| !in \| exists \| elemMatch \| >size \| <size` | Operator | |
248
+ | `value` | `any` | Filter value (array for `><` and `in`) | |
249
+ | `queryId` | `string` | Field name | Required except `search` |
250
+ | `searchIds` | `string[]` | Fields to search across | Required for `search` |
251
+ | `orQuery` | `boolean` | Use OR instead of AND | ✓ |
252
+ | `orQueryGroupId` | `string` | AND inside a block, OR between blocks | ✓ |
253
253
 
254
254
  ## Aggregate
255
255
 
@@ -44,3 +44,4 @@ import { Overlay } from "blixify-ui-web/components/display/overlay";
44
44
  | `bgColor` | `string` | `bg-white` | Tailwind background class for the panel in light mode |
45
45
  | `darkMode` | `boolean` | `false` | Switches the panel, text and icons to the dark palette |
46
46
  | `darkModeBg` | `string` | `bg-gray-900` | Tailwind background class for the panel when `darkMode` is on |
47
+ | `zIndex` | `string` | `z-40` | Tailwind z-index class for the dialog. Pass a higher one for a modal opened from a layer drawn above z-40, such as the activity drawer (z-50) |
@@ -41,6 +41,36 @@ The legacy `List` renders **every** extra key on an item. `NewList` renders only
41
41
  <NewList list={tasks} extraKeys={["details", "activity"]} />
42
42
  ```
43
43
 
44
+ ## Compact rows
45
+
46
+ Set `compact` on an item to draw it as a two-line row, like a mail inbox: the title with a pill and a short date on the first line, one grey line under it, and an arrow that opens the rest. Every compact row is the same height. An item without `compact` keeps the layout above, so existing keys such as an `extraKeys` "details" keep their meaning.
47
+
48
+ | `compact` field | Rendered as |
49
+ | --------------- | ----------------------------------------------------------------------------- |
50
+ | `subtitle` | One grey line under the title, cut to fit |
51
+ | `details` | Shown under the row when its arrow is tapped; the arrow does not open the row |
52
+ | `badge` | A pill leading the second line. Without it, the item's `tags` / `tag` |
53
+ | `trailing` | The right end of the title line. Without it, the item's `date` |
54
+
55
+ ```tsx
56
+ <NewList
57
+ list={[
58
+ {
59
+ id: "a1",
60
+ title: "Ahmad Solar House",
61
+ compact: {
62
+ badge: <Tag tagText="Done" color="green" size="small" />,
63
+ trailing: "12 Nov",
64
+ subtitle: "Selangor · RM 12,400",
65
+ details: <DetailList title="" list={fields} size="compact" />,
66
+ },
67
+ },
68
+ ]}
69
+ />
70
+ ```
71
+
72
+ `NewListCompactRow` (`newList/compactRow`) is the row on its own; the new table draws its phone rows with it. `handlePickListRowFields` (`newList/listRowFields`) picks a row's fields from a column list in view order: the first date column for `trailing`, the first list column for `badge`, and the next two columns that hold something for `subtitle`. Pictures, files, drawings, long text, nested lists, buttons, timers and star ratings are left off the second line. `handleFormatListRowDate` writes a date as "12 Nov", adding the year outside the current one.
73
+
44
74
  ## Date
45
75
 
46
76
  | `date` value | Output |
@@ -1,12 +1,14 @@
1
1
  # NewTable
2
2
 
3
- Tabular display with a List-shaped mobile breakpoint. The architectural key: **`mobileView=true` delegates row rendering to `NewListRow`**, so the same tap-rhythm and avatar/title/trailing geometry shows up across NewList, NewFeeds, and NewTable mobile.
3
+ Tabular display with a List-shaped mobile breakpoint. With `mobileView=true` each row is a two-line `NewListCompactRow`, like a mail inbox: the title and a short date, then a pill and a grey line of key values, and an arrow that opens every field. Tapping the row opens the record (`onClickRow` / `newTabUrl`), the checkbox ticks it without opening it, and groups draw the same foldable headers, counts and per-group paging as the computer layout. Rows are drawn again only when their own data changes, and a group with 120 or more loaded rows draws only the rows near the screen.
4
+
5
+ Without `mobileTooltip.titleAdjacent`, the view's column order picks a row's fields (`handlePickListRowFields`): the first list column is the pill, the first date column the date, and the next two columns that hold something the grey line.
4
6
 
5
7
  ## Row activity
6
8
 
7
9
  Pass `onRowActivity={(id) => ...}` to show a keyboard-accessible chat button at the right of the first visible cell (beside the title on mobile cards). It stops row navigation and does not enter inline editing. When no left pins are configured, the first ungrouped column is pinned automatically; existing pin configurations are preserved. DTW wires this callback automatically when `edit.activityLog` is enabled with `bareNewDTW`.
8
10
 
9
- Pass `onRowEdit={(id) => ...}` as well to put an edit pencil before the chat button, at the same size and colour. Both sit over the cell's right padding, so the row does not grow. DTW passes it only when the user can update records (`edit.allEditable`) and opens the record's edit form with `handleNavigate("update", id, "general")`.
11
+ Pass `onRowEdit={(id) => ...}` as well to put an edit pencil before the chat button, at the same size and colour, and `onRowOpen={(id) => ...}` to put a chevron beside the title. The pencil and the chat button each sit in their own divided strip at the title cell's right edge, like columns of their own, with the chevron just left of them. The chevron shows only when the row is hovered or the chevron is focused, and always on a touch screen, which cannot hover. While it shows, the title cell's padding grows to take it, so it never covers the title or its inline editor. All of them sit over the cell's right padding, so the row does not grow. While the title carries these actions, its column cannot be dragged — or set, or saved — narrower than its left padding, the actions' room (with the chevron's) and 48px of title; other columns keep the 80px floor. DTW passes the pencil only when the user can update records and opens the edit form with `handleNavigate("update", id, "general")`. It passes the chevron to anyone who can see the record, when `organise.handleNavigate` is set, and opens the read view with `handleNavigate("read", id, "general")` — in the drawer when `viewSettings.readDrawer` is set, as a page otherwise. An editable title cell is hard to click open, so the chevron is the sure way in. Mobile cards open from the card, so they show the pencil and the chat button but no chevron.
10
12
 
11
13
  ## Adding a row
12
14
 
@@ -26,18 +28,18 @@ Columns flagged `hideOnMobile` are dropped from the mobile rendering entirely.
26
28
 
27
29
  ## Mobile tooltip (`mobileTooltip`)
28
30
 
29
- With `mobileView`, `mobileTooltip` adds row data to the collapsed row and to a hover card. Through `DataTemplate` it is `bareSettings.bareMobileTooltip`, which applies when `bareNewDTW` and `bareListMobileView` are both on.
31
+ With `mobileView`, `mobileTooltip` picks what the row shows, in place of the column order, and adds a hover card. Through `DataTemplate` it is `bareSettings.bareMobileTooltip`, which applies when `bareNewDTW` and `bareListMobileView` are both on.
30
32
 
31
- | Key | Type | Shows |
32
- | --------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
33
- | `titleAdjacent.tagKey` | `string` | That row field's value as a Tag next to the row title |
34
- | `titleAdjacent.tagColor` | `Record<string, TagColor>` | The Tag colour for each value |
35
- | `titleAdjacent.trailingKey` | `string` | That row field's value as trailing text, e.g. a price |
36
- | `titleAdjacent.summaryKeys` | `string[]` | A second line under the title while the row is collapsed, as label + value pairs. Labels come from the column titles; a dotted key (`shippingAddress.name`) falls back to its parent column's title. Empty values are skipped. Tapping the line expands the row |
37
- | `tooltip.imageKey` | `string` | The image URL field shown in the hover card |
38
- | `tooltip.summaryKeys` | `string[]` | Label + value pairs in the hover card |
39
- | `tooltip.tagKeys` | `string[]` | Tag pills in the hover card |
40
- | `tooltip.tagColors` | `Record<string, TagColor>` | The Tag colour for each value |
33
+ | Key | Type | Shows |
34
+ | --------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
35
+ | `titleAdjacent.tagKey` | `string` | That row field's value as a Tag next to the row title |
36
+ | `titleAdjacent.tagColor` | `Record<string, TagColor>` | The Tag colour for each value |
37
+ | `titleAdjacent.trailingKey` | `string` | That row field's value at the right of the title line, e.g. a price. Without it the row shows no date |
38
+ | `titleAdjacent.summaryKeys` | `string[]` | The grey line under the title, as "Label: value" pairs joined with " · ". Labels come from the column titles; a dotted key (`shippingAddress.name`) falls back to its parent column's title. Empty values, hidden columns, the title and the `tagKey` column are skipped. Without it, and with `titleAdjacent` set, the line is left out |
39
+ | `tooltip.imageKey` | `string` | The image URL field shown in the hover card |
40
+ | `tooltip.summaryKeys` | `string[]` | Label + value pairs in the hover card |
41
+ | `tooltip.tagKeys` | `string[]` | Tag pills in the hover card |
42
+ | `tooltip.tagColors` | `Record<string, TagColor>` | The Tag colour for each value |
41
43
 
42
44
  ```tsx
43
45
  <NewTable
@@ -117,8 +119,11 @@ Types that need more than a name and a type are completed in the popup:
117
119
  | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- |
118
120
  | `reference`, `listReference`, `referenceSelection` | endpoint and label, from `renderReferenceSpaceOptions` / `renderReferenceLabelOptions` — required, since a reference with no target resolves nothing |
119
121
  | `list`, `multipleList` | options, as a comma-separated line |
122
+ | `lookup` | a `reference` or `listReference` column of this table, then a column of the space it links to, from `renderReferenceLabelOptions(spaceId, true)`; `lookup`, `password` and `button` columns are left out. Offered only with `renderReferenceLabelOptions`. Saved as `{ type: "lookup", referenceFieldId, lookupFieldId }`, always optional and read-only |
120
123
  | `image`, `file`, `multipleImage`, `multipleFile` | nothing — the consumer fills the storage path from its own settings |
121
124
 
125
+ A `reference` or `listReference` column has a solid line under its header, and a `lookup` column (or a column drawn from one, carrying `lookupFieldId`) a dashed one, both in the primary colour.
126
+
122
127
  A **Required** toggle sets the field's `optional` flag. New columns default to
123
128
  optional, since the rows already in the table have no value for one the moment it
124
129
  is added — ticking it says so.
@@ -206,10 +211,10 @@ With `editable.active` most columns edit in the cell (`supportedInlineEditList`)
206
211
 
207
212
  `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.
208
213
 
209
- | Prop | Type | Behavior |
210
- | -------------------- | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
211
- | `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 |
212
- | `onGroupLoadMore` | `(groupId: string) => void` | Scroll paging: fired when the bottom of a band scrolls into view, so that band loads its next rows |
213
- | `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 |
214
- | `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` |
215
-
214
+ | Prop | Type | Behavior |
215
+ | -------------------- | --------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
216
+ | `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 |
217
+ | `groupColumnKey` | `string` | The column the groups are built from. Phone rows leave it off, since the group header shows it. Group headers show on a phone while their rows load |
218
+ | `onGroupLoadMore` | `(groupId: string) => void` | Scroll paging: fired when the bottom of a band scrolls into view, so that band loads its next rows |
219
+ | `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 |
220
+ | `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` |