blixify-ui-web-mcp 0.1.75 → 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.
@@ -480,7 +480,7 @@ Under `bareNewDTW` the grid hides `Grid`'s own page buttons and renders the same
480
480
 
481
481
  Set `organise.viewId="grid"` (or add an `extendedTabs` entry with `viewType="grid"`) to render records as cards. Use `bareSettings.bareListGridComponent` to fully customise each card, or use the built-in grid. `viewImage` is optional: point it at an image or multipleImage column to show a picture on each card, or leave it empty for cards with only the title and fields. The Add Tab modal offers **Grid** on every space, and shows the Preview Image picker only when the space has an image column.
482
482
 
483
- The list, kanban and grid items show the view's visible columns in column order, with the table's cell renderers. The title column is the item's title, the grid's image column is its picture, and a column with nothing stored is left out. A button, timer or rating is always shown. Each item carries the edit pencil (only for users who can update) and the comment bubble (when `edit.activityLog` is on) beside its title.
483
+ The list, kanban and grid items show the view's visible columns in column order, with the table's cell renderers. Under `bareNewDTW` a list item is a two-line row like a mail inbox (`NewList` `compact`): the first list column as a pill leading the second line, the first date column as a short date at the right, the next two columns that hold something on a grey line after the pill, and every field behind the row's arrow. On a wide screen the row is one line: title, pill, values, date. The grouped column is left off rows under group sections. `bareListDescription` still renders each value, the row's date included, and `bareListComponent` still replaces the row. The title column is the item's title, the grid's image column is its picture, and a column with nothing stored is left out. A button, timer or rating is left off the row's second line and shown behind its arrow; grid and kanban cards always show them. Each item carries the edit pencil (only for users who can update) and the comment bubble (when `edit.activityLog` is on) beside its title. The table also shows a chevron beside each title that opens the read view, for anyone who can see the record when `organise.handleNavigate` is set, since an editable title cell is hard to click open; its pencil and bubble sit in divided columns at the title cell's edge.
484
484
 
485
485
  ```tsx
486
486
  <DataTemplate
@@ -766,6 +766,7 @@ Column types with monday.com counterparts. The value maths is in `src/components
766
766
  | Time tracking | `type: "timeTracking"` | `{ totalSeconds, running, startedAt?, sessions? }` | table, read view and the record form show the total with start / stop, ticking each second inside the cell only; the form draws it as a field box, read-only on a record not created yet or without update rights. Start / stop saves at once, apart from Submit, and a form save never sends the timer. With `organise.onTimeTrackingAction` a click sends only `"start"` / `"stop"` and shows the value the host's server stored, timed on the server's clock (the cell ticks at once and is corrected to the reply, or put back if it fails); without it the cell works the value out and saves that one field. `handleStartTimeTracking` / `handleStopTimeTracking` are idempotent, so a flow can drive the same value. Sorts on `<id>.totalSeconds`; formulas read hours (`handleGetTimeTrackingHours`) |
767
767
  | Date range | `type: "dateRange"`, `minDate?`, `maxDate?` | `{ start, end }` | `12 – 18 Nov`; a range picker in the form; in an editable table the whole cell opens it, with the inline date cell's border on hover; filters and sorts on `<id>.start`. A calendar or timeline tab can use `<id>.start` and `<id>.end` as its dates |
768
768
  | Rating | `type: "number"`, `ratingMax` | a number | stars in every view; a tap saves the number, tapping the current rating clears it |
769
+ | Lookup | `type: "lookup"`, `referenceFieldId`, `lookupFieldId`, `lookupFieldType?`, `lookupFieldSettings?` | the linked record's `lookupFieldId` value, written by the host's server | every view draws it as `lookupFieldType` (a `multipleList` through a `listReference`) with `lookupFieldSettings`, disabled and optional; `organise.onAddColumns` gets it back as stored, with only its name, width and visibility taken from the table; the column dialog edits it as a lookup |
769
770
 
770
771
  `bareSettings.bareSystemColumns` adds the server-stamped columns without declaring them: Created At / By (`baseCreatedAt`, `baseCreatedBy`), Updated At / By (`baseUpdatedAt`, `baseUpdatedBy`), Record ID (the record's `_id`) and Number (`baseNumber`). They start hidden; showing one is kept per view (`shownSystemColumnIds`) and never written back through `onChangeHide` or `onChangeDrag`. They are read-only, sortable and filterable, empty on a record the server never stamped, and a form save never sends them (nor a timer or a button).
771
772
 
@@ -106,6 +106,8 @@ const handleInstallMock = (rows: any[]) => {
106
106
  inFlight: 0,
107
107
  maxInFlight: 0,
108
108
  });
109
+ // INFO: e2e reads how many table rows were drawn
110
+ (window as any).__newTableRowDraws = { count: 0 };
109
111
  const handleDelay = () => new Promise((resolve) => setTimeout(resolve, 400));
110
112
  mock.onPost(`${ENDPOINT}/${COLLECTION}/list`).reply(async (config) => {
111
113
  const body = JSON.parse(config.data ?? "{}");
@@ -155,6 +157,12 @@ const handleInstallMock = (rows: any[]) => {
155
157
  rows.unshift(row);
156
158
  return [200, { data: row._id }];
157
159
  });
160
+ mock.onPost(`${ENDPOINT}/${COLLECTION}/update`).reply((config) => {
161
+ const body = JSON.parse(config.data ?? "{}");
162
+ const row = rows.find((each) => each._id === body.id);
163
+ if (row) Object.assign(row, body.data ?? {});
164
+ return [200, { data: body.id }];
165
+ });
158
166
  mock.onPost(`${ENDPOINT}/${COLLECTION}/get`).reply((config) => {
159
167
  const body = JSON.parse(config.data ?? "{}");
160
168
  return [200, { data: rows.find((row) => row._id === body.id) ?? null }];
@@ -181,8 +189,9 @@ const VIEW_NAMES = [
181
189
  "1.42 Wang's Lead Board · Drawing",
182
190
  ];
183
191
 
184
- const GroupedBoardDemo = () => {
192
+ const GroupedBoardDemo = (props: { isPhone?: boolean }) => {
185
193
  const rows = useMemo(() => handleBuildRows(), []);
194
+ const [openedId, setOpenedId] = useState("");
186
195
  useMemo(() => handleInstallMock(rows), [rows]);
187
196
  const [sharedViews, setSharedViews] = useState<SharedSavedView[]>(() => [
188
197
  {
@@ -204,40 +213,61 @@ const GroupedBoardDemo = () => {
204
213
  ]);
205
214
 
206
215
  return (
207
- <DataTemplateImpl
208
- title="Master Leads"
209
- description="148,936 leads grouped by state"
210
- type="list"
211
- collectionId={COLLECTION}
212
- model={SCHEMA}
213
- localURLEndpoint=""
214
- devSettings={{
215
- server: "mongoServer",
216
- dbEndpoint: ENDPOINT,
217
- dbAPIToken: "",
218
- dbUserToken: "",
219
- schemaEndpoint: "",
220
- imageEndpoint: "",
221
- }}
222
- lib={{ axios, Menu, Transition }}
223
- edit={{ listAddable: true }}
224
- limit={50}
225
- bareSettings={{ bareNewDTW: true, bareFlexFilters: true }}
226
- organise={{
227
- viewId: "general",
228
- datasheetDefaults: true,
229
- showGroupByFeature: true,
230
- groupBy: "area",
231
- paginationType: "scroll",
232
- savedViews: true,
233
- sharedViews,
234
- onChangeSharedViews: setSharedViews,
235
- renderGroupByOptions: () => [
236
- { key: "area", label: "State (Area)" },
237
- { key: "status", label: "Status" },
238
- ],
239
- }}
240
- />
216
+ <>
217
+ {props.isPhone && (
218
+ <div data-testid="grouped-board-opened" className="mb-2 text-xs">
219
+ {openedId ? `Opened ${openedId}` : ""}
220
+ </div>
221
+ )}
222
+ <DataTemplateImpl
223
+ title="Master Leads"
224
+ description="148,936 leads grouped by state"
225
+ type="list"
226
+ collectionId={COLLECTION}
227
+ model={SCHEMA}
228
+ localURLEndpoint=""
229
+ devSettings={{
230
+ server: "mongoServer",
231
+ dbEndpoint: ENDPOINT,
232
+ dbAPIToken: "",
233
+ dbUserToken: "",
234
+ schemaEndpoint: "",
235
+ imageEndpoint: "",
236
+ }}
237
+ lib={{ axios, Menu, Transition }}
238
+ edit={{ listAddable: true, listEditable: true }}
239
+ limit={50}
240
+ bareSettings={{
241
+ bareNewDTW: true,
242
+ bareFlexFilters: true,
243
+ bareListMobileView: props.isPhone,
244
+ // INFO: rows can be ticked on the phone board
245
+ ...(props.isPhone
246
+ ? { bareListMultipleSelectAddtionalActions: [] }
247
+ : {}),
248
+ }}
249
+ organise={{
250
+ viewId: "general",
251
+ datasheetDefaults: true,
252
+ showGroupByFeature: true,
253
+ groupBy: "area",
254
+ paginationType: "scroll",
255
+ savedViews: true,
256
+ sharedViews,
257
+ onChangeSharedViews: setSharedViews,
258
+ renderGroupByOptions: () => [
259
+ { key: "area", label: "State (Area)" },
260
+ { key: "status", label: "Status" },
261
+ ],
262
+ ...(props.isPhone
263
+ ? {
264
+ handleNavigate: (_type: string, id?: string) =>
265
+ setOpenedId(id ?? ""),
266
+ }
267
+ : {}),
268
+ }}
269
+ />
270
+ </>
241
271
  );
242
272
  };
243
273
 
@@ -252,8 +282,30 @@ export const GroupedLargeBoard = () => (
252
282
  Scroll up and down: every band shows its full total from the start, bands
253
283
  not read yet draw no "Add item" rows, and bands load one at a time. The 16
254
284
  views fold behind "+N more"; the pencil on a view renames it, and "Closed
255
- only" filters every band.
285
+ only" filters every band. Cells edit in place on hover, and only the rows
286
+ near the screen are drawn.
256
287
  </div>
257
288
  <GroupedBoardDemo />
258
289
  </div>
259
290
  );
291
+
292
+ export const GroupedLargeBoardPhone = () => (
293
+ <div className="w-full max-w-sm">
294
+ <div className="mb-1 font-semibold text-gray-900">
295
+ Data Template — the large grouped board on a phone
296
+ </div>
297
+ <div className="mb-4 text-sm text-gray-600">
298
+ The same 148,936 leads in the phone layout. Each row reads like a mail
299
+ inbox: the name and date on top, the status pill under it, and an arrow
300
+ that opens every field. The state is left off the row, as its header
301
+ already says it. Tapping a row opens it, the box ticks it, and each
302
+ state&apos;s header folds its rows. Only the rows near the screen are
303
+ drawn.
304
+ </div>
305
+ <GroupedBoardDemo isPhone />
306
+ </div>
307
+ );
308
+
309
+ GroupedLargeBoardPhone.parameters = {
310
+ viewport: { defaultViewport: "mobile2" },
311
+ };
@@ -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
+ );
@@ -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` |
@@ -833,3 +833,70 @@ export const RowLinks = () => {
833
833
  </StorySection>
834
834
  );
835
835
  };
836
+
837
+ // INFO: reference columns carry a solid line under their header and lookup
838
+ // columns a dashed one; a lookup is drawn as its source column's type
839
+ export const LinkedColumns = () => {
840
+ const columns: NewTableColumn[] = [
841
+ { key: "name", title: "Customer", type: "string" },
842
+ {
843
+ key: "project",
844
+ title: "PV Project",
845
+ type: "reference",
846
+ referenceSpaceId: "projects",
847
+ referenceLabel: "code",
848
+ },
849
+ {
850
+ key: "projectStatus",
851
+ title: "Project Status",
852
+ type: "string",
853
+ referenceFieldId: "project",
854
+ lookupFieldId: "status",
855
+ disabled: true,
856
+ },
857
+ {
858
+ key: "installer",
859
+ title: "Installer",
860
+ type: "string",
861
+ referenceFieldId: "project",
862
+ lookupFieldId: "installer",
863
+ disabled: true,
864
+ },
865
+ { key: "notes", title: "Notes", type: "string" },
866
+ ];
867
+ const rows = [
868
+ {
869
+ id: "1",
870
+ name: "Andrew Tan",
871
+ project: "PV-1023",
872
+ projectStatus: "Installed",
873
+ installer: "Ahmad",
874
+ notes: "Call before visit",
875
+ },
876
+ {
877
+ id: "2",
878
+ name: "Celine Wong",
879
+ project: "PV-1031",
880
+ projectStatus: "Scheduled",
881
+ installer: "Kumar",
882
+ notes: "",
883
+ },
884
+ ];
885
+ return (
886
+ <StorySection
887
+ title="Linked columns"
888
+ description="A reference column has a solid line under its header; a lookup column, filled from the linked record, has a dashed one."
889
+ >
890
+ <NewTableImpl
891
+ columns={columns}
892
+ data={rows}
893
+ size={rows.length}
894
+ limit={10}
895
+ cursor=""
896
+ loading={false}
897
+ paginationType="page"
898
+ handleUpdatePageIndex={() => {}}
899
+ />
900
+ </StorySection>
901
+ );
902
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.75",
3
+ "version": "0.1.76",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {