blixify-ui-web-mcp 0.1.62 → 0.1.64

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,393 @@
1
+ import { Menu, Transition } from "@headlessui/react";
2
+ import axios from "axios";
3
+ import MockAdapter from "axios-mock-adapter";
4
+ import React, { useMemo, useState } from "react";
5
+ import { DataTemplate } from "../dataTemplate";
6
+ import { DataStructure } from "../dataTemplate/model";
7
+ import { ButtonCell } from "./buttonCell";
8
+ import { ColumnTypeContext } from "./context";
9
+ import { RatingStars } from "./ratingCell";
10
+ import { TimeTrackingCell } from "./timeTrackingCell";
11
+
12
+ const meta = { title: "Data/Column Types" };
13
+ export default meta;
14
+
15
+ // INFO: an in-browser endpoint — every read and write below stays in memory
16
+ const DEMO_ENDPOINT = "https://column-types-demo.local/api";
17
+ const JOBS_COLLECTION = "demo-jobs";
18
+ const BOARD_COLLECTION = "demo-board";
19
+
20
+ const MEMBERS = [
21
+ { key: "u-alice", label: "Alice", image: "" },
22
+ { key: "u-bob", label: "Bob", image: "" },
23
+ { key: "u-carol", label: "Carol", image: "" },
24
+ { key: "u-dan", label: "Dan", image: "" },
25
+ ];
26
+
27
+ const demoDevSettings: any = {
28
+ server: "mongoServer",
29
+ dbEndpoint: DEMO_ENDPOINT,
30
+ dbAPIToken: "",
31
+ dbUserToken: "",
32
+ schemaEndpoint: "",
33
+ imageEndpoint: "",
34
+ organisationMembers: MEMBERS,
35
+ };
36
+
37
+ const JOB_SCHEMA: DataStructure[] = [
38
+ { id: "name", name: "Job", type: "string" },
39
+ {
40
+ id: "stage",
41
+ name: "Stage",
42
+ type: "list",
43
+ listSelection: ["Survey", "Install", "Handover"],
44
+ listSelectionColour: ["orange", "blue", "green"],
45
+ optional: true,
46
+ },
47
+ {
48
+ id: "generate",
49
+ name: "Quote",
50
+ type: "button",
51
+ buttonLabel: "Generate PDF",
52
+ buttonColour: "primary",
53
+ },
54
+ {
55
+ id: "moveOn",
56
+ name: "Move On",
57
+ type: "button",
58
+ buttonLabel: "Move on",
59
+ buttonColour: "green",
60
+ buttonConfirm: "Move this job to the next stage?",
61
+ },
62
+ { id: "work", name: "Site Time", type: "timeTracking", optional: true },
63
+ { id: "timeline", name: "Timeline", type: "dateRange", optional: true },
64
+ {
65
+ id: "score",
66
+ name: "Customer Rating",
67
+ type: "number",
68
+ ratingMax: 5,
69
+ optional: true,
70
+ },
71
+ {
72
+ id: "daysLeft",
73
+ name: "Days Planned",
74
+ type: "formula",
75
+ formulaExpression: "DAYS({timeline#end}, {timeline#start})",
76
+ },
77
+ {
78
+ id: "hoursText",
79
+ name: "Hours",
80
+ type: "formula",
81
+ formulaExpression: "ROUND({work}, 2)",
82
+ },
83
+ ];
84
+
85
+ const handleSeedJobs = () => {
86
+ const today = new Date();
87
+ const handleDay = (offset: number) =>
88
+ new Date(
89
+ today.getFullYear(),
90
+ today.getMonth(),
91
+ today.getDate() + offset,
92
+ ).toISOString();
93
+ return [
94
+ {
95
+ _id: "job-1",
96
+ name: "Ipoh rooftop",
97
+ stage: "Survey",
98
+ work: { totalSeconds: 5400, running: false },
99
+ timeline: { start: handleDay(2), end: handleDay(8) },
100
+ score: 4,
101
+ baseNumber: 101,
102
+ baseCreatedAt: handleDay(-20),
103
+ baseCreatedBy: "u-alice",
104
+ },
105
+ {
106
+ _id: "job-2",
107
+ name: "Shah Alam warehouse",
108
+ stage: "Install",
109
+ work: {
110
+ totalSeconds: 600,
111
+ running: true,
112
+ startedAt: new Date(Date.now() - 90 * 1000).toISOString(),
113
+ },
114
+ timeline: { start: handleDay(-3), end: handleDay(25) },
115
+ baseNumber: 102,
116
+ baseCreatedAt: handleDay(-10),
117
+ baseCreatedBy: "u-bob",
118
+ },
119
+ {
120
+ _id: "job-3",
121
+ name: "Penang terrace (older record, no stamps)",
122
+ stage: "Archived",
123
+ },
124
+ ];
125
+ };
126
+
127
+ const handleIsIn = (list: any[], value: any) =>
128
+ list.some((each) =>
129
+ each === null ? value === null || value === undefined : each === value,
130
+ );
131
+
132
+ const handleMatchQuery = (row: any, query: any[]) =>
133
+ (Array.isArray(query) ? query : []).every((eachQuery) => {
134
+ if (!eachQuery?.queryId) return true;
135
+ const value = eachQuery.queryId
136
+ .split(".")
137
+ .reduce((acc: any, key: string) => acc?.[key], row);
138
+ switch (eachQuery.type) {
139
+ case "=":
140
+ return String(value) === String(eachQuery.value);
141
+ case "in":
142
+ return handleIsIn(eachQuery.value, value);
143
+ case "!in":
144
+ return !handleIsIn(eachQuery.value, value);
145
+ default:
146
+ return true;
147
+ }
148
+ });
149
+
150
+ // INFO: list, get and update over one array per collection, so a timer
151
+ // started or a rating given in the story survives a refresh of the list
152
+ const handleInstallMock = (stores: { [collectionId: string]: any[] }) => {
153
+ const mock = new MockAdapter(axios, {
154
+ onNoMatch: "passthrough",
155
+ delayResponse: 200,
156
+ });
157
+ const handleBody = (config: any) => JSON.parse(config?.data ?? "{}");
158
+ Object.entries(stores).forEach(([collectionId, store]) => {
159
+ const url = (type: string) => `${DEMO_ENDPOINT}/${collectionId}/${type}`;
160
+ mock.onPost(url("list")).reply((config) => {
161
+ const body = handleBody(config);
162
+ const rows = store.filter((each) => handleMatchQuery(each, body.query));
163
+ const cursor = typeof body.cursor === "number" ? body.cursor : 0;
164
+ const limit = typeof body.limit === "number" ? body.limit : rows.length;
165
+ return [
166
+ 200,
167
+ {
168
+ data: JSON.parse(JSON.stringify(rows.slice(cursor, cursor + limit))),
169
+ count: rows.length,
170
+ },
171
+ ];
172
+ });
173
+ mock.onPost(url("get")).reply((config) => {
174
+ const body = handleBody(config);
175
+ const row = store.find((each) => each._id === body.id);
176
+ return [200, { data: row ? JSON.parse(JSON.stringify(row)) : null }];
177
+ });
178
+ mock.onPost(url("update")).reply((config) => {
179
+ const body = handleBody(config);
180
+ const row = store.find((each) => each._id === body.id);
181
+ if (row)
182
+ Object.assign(row, body.data, {
183
+ baseUpdatedAt: new Date().toISOString(),
184
+ baseUpdatedBy: "u-alice",
185
+ });
186
+ return [200, { success: true }];
187
+ });
188
+ });
189
+ return mock;
190
+ };
191
+
192
+ export const MondayParityColumns = () => {
193
+ // 1. State
194
+ const [type, setType] = useState<"list" | "read" | "update">("list");
195
+ const [id, setId] = useState("");
196
+ const [log, setLog] = useState<string[]>([]);
197
+
198
+ // 2. Refs
199
+ const jobs = useMemo(() => handleSeedJobs(), []);
200
+ useMemo(() => handleInstallMock({ [JOBS_COLLECTION]: jobs }), [jobs]);
201
+
202
+ // 5. Handlers
203
+ // INFO: stands in for the host — Space posts the click to its button route
204
+ const handleButtonClick = async (columnId: string, row: any) => {
205
+ await new Promise((resolve) => setTimeout(resolve, 1500));
206
+ setLog((previous) => [
207
+ `${new Date().toLocaleTimeString()} — ${columnId} on ${row.name}`,
208
+ ...previous,
209
+ ]);
210
+ };
211
+
212
+ // 7. Return
213
+ return (
214
+ <div className="w-full space-y-4">
215
+ <div className="text-sm text-gray-600">
216
+ A button column (Generate PDF; Move on asks first), a time tracking
217
+ column that ticks live, a timeline (date range), a 5-star rating and two
218
+ formulas reading them. System columns (Created At / By, Updated At / By,
219
+ Record ID, Number) are offered hidden in the column settings. The third
220
+ record is an older one with no stamps.
221
+ </div>
222
+ <DataTemplate
223
+ title="Jobs"
224
+ description="Monday parity column types"
225
+ type={type}
226
+ id={id}
227
+ collectionId={JOBS_COLLECTION}
228
+ model={JOB_SCHEMA}
229
+ localURLEndpoint=""
230
+ devSettings={demoDevSettings}
231
+ lib={{ axios, Menu, Transition }}
232
+ edit={{ allEditable: true, listEditable: true }}
233
+ bareSettings={{ bareNewDTW: true, bareSystemColumns: true }}
234
+ organise={{
235
+ paginationType: "page",
236
+ showGroupByFeature: true,
237
+ onButtonClick: handleButtonClick,
238
+ handleNavigate: (nextType: string, nextId?: string) => {
239
+ setType(nextType as any);
240
+ setId(nextId ?? "");
241
+ },
242
+ }}
243
+ />
244
+ <div data-testid="button-click-log" className="text-xs text-gray-500">
245
+ {log.length === 0
246
+ ? "Button clicks appear here."
247
+ : log.map((each) => <div key={each}>{each}</div>)}
248
+ </div>
249
+ </div>
250
+ );
251
+ };
252
+
253
+ const BOARD_STATUSES = ["Working on it", "Stuck", "Done"];
254
+
255
+ const handleSeedBoard = () =>
256
+ Array.from({ length: 2300 }, (_, index) => ({
257
+ _id: `board-${index}`,
258
+ name: `Task #${index}`,
259
+ // INFO: every 50th row has no owner and every 70th a member who left, so
260
+ // the No Value and Unknown sections are both there
261
+ owner:
262
+ index % 50 === 49
263
+ ? ""
264
+ : index % 70 === 69
265
+ ? "u-left"
266
+ : MEMBERS[index % MEMBERS.length].key,
267
+ status:
268
+ index % 45 === 44 ? "" : BOARD_STATUSES[index % BOARD_STATUSES.length],
269
+ done: index % 3 === 0,
270
+ score: index % 6,
271
+ }));
272
+
273
+ const BOARD_SCHEMA: DataStructure[] = [
274
+ { id: "name", name: "Task", type: "string" },
275
+ {
276
+ id: "status",
277
+ name: "Status",
278
+ type: "list",
279
+ listSelection: BOARD_STATUSES,
280
+ listSelectionColour: ["orange", "red", "green"],
281
+ optional: true,
282
+ },
283
+ { id: "owner", name: "Owner", type: "person", optional: true },
284
+ { id: "done", name: "Checked", type: "boolean", optional: true },
285
+ {
286
+ id: "score",
287
+ name: "Priority",
288
+ type: "number",
289
+ ratingMax: 5,
290
+ optional: true,
291
+ },
292
+ ];
293
+
294
+ export const GroupedBoard2300Rows = () => {
295
+ // 1. State
296
+ const [groupBy, setGroupBy] = useState("owner");
297
+
298
+ // 2. Refs
299
+ const board = useMemo(() => handleSeedBoard(), []);
300
+ useMemo(() => handleInstallMock({ [BOARD_COLLECTION]: board }), [board]);
301
+
302
+ // 7. Return
303
+ return (
304
+ <div className="w-full space-y-4">
305
+ <div className="text-sm text-gray-600">
306
+ 2,300 tasks read in one page and grouped into sections. Grouped by Owner
307
+ (a person column) the sections follow the member order, then Unknown (a
308
+ member who left) and No Value; each section draws 50 rows and a Show
309
+ more. Grouped by Status or Checked each section reads and pages its own
310
+ rows.
311
+ </div>
312
+ <DataTemplate
313
+ key={groupBy}
314
+ title="Board"
315
+ description="Grouped into sections"
316
+ type="list"
317
+ collectionId={BOARD_COLLECTION}
318
+ model={BOARD_SCHEMA}
319
+ localURLEndpoint=""
320
+ devSettings={demoDevSettings}
321
+ lib={{ axios, Menu, Transition }}
322
+ limit={groupBy === "owner" ? 2300 : 25}
323
+ edit={{ allEditable: true }}
324
+ bareSettings={{ bareNewDTW: true }}
325
+ organise={{
326
+ paginationType: groupBy === "owner" ? "page" : "scroll",
327
+ showGroupByFeature: true,
328
+ groupBy,
329
+ onChangeGroupBy: (value: string) => setGroupBy(value || "owner"),
330
+ }}
331
+ />
332
+ </div>
333
+ );
334
+ };
335
+
336
+ // INFO: the cells on their own, outside a DataTemplate — the context is what
337
+ // a DataTemplate provides
338
+ export const Cells = () => {
339
+ // 1. State
340
+ const [score, setScore] = useState(3);
341
+ const [timer, setTimer] = useState<any>({
342
+ totalSeconds: 3725,
343
+ running: false,
344
+ });
345
+
346
+ // 2. Refs
347
+ const column = (overrides: Partial<DataStructure>): DataStructure => ({
348
+ id: "cell",
349
+ name: "Cell",
350
+ type: "string",
351
+ ...overrides,
352
+ });
353
+
354
+ // 7. Return
355
+ return (
356
+ <ColumnTypeContext.Provider
357
+ value={{
358
+ canUpdate: true,
359
+ onButtonClick: () =>
360
+ new Promise((resolve) => setTimeout(resolve, 1500)),
361
+ onUpdateCell: async (_rowId, _columnId, value) => setTimer(value),
362
+ }}
363
+ >
364
+ <div className="max-w-sm space-y-6">
365
+ <ButtonCell
366
+ column={column({ type: "button", buttonLabel: "Generate document" })}
367
+ row={{ _id: "demo" }}
368
+ />
369
+ <ButtonCell
370
+ column={column({
371
+ type: "button",
372
+ buttonLabel: "Archive",
373
+ buttonColour: "red",
374
+ buttonConfirm: "Archive this item?",
375
+ })}
376
+ row={{ _id: "demo" }}
377
+ />
378
+ <TimeTrackingCell
379
+ column={column({ id: "timer", type: "timeTracking" })}
380
+ rowId="demo"
381
+ value={timer}
382
+ />
383
+ <RatingStars
384
+ id="stars"
385
+ value={score}
386
+ max={5}
387
+ size="form"
388
+ onChange={setScore}
389
+ />
390
+ </div>
391
+ </ColumnTypeContext.Provider>
392
+ );
393
+ };
@@ -189,6 +189,12 @@ Custom layout via renderSettings:
189
189
  | `topN` | `number` | Keep the first N groups; the rest are added up as "Others" unless `showOthers` is `false` (averages never get "Others"). |
190
190
  | `showPercent` | `boolean` | Show each group as a percentage of the total (count and sum, unsplit charts). |
191
191
  | `cumulative` | `boolean` | Running total over the date buckets. |
192
+ | `datesAcross` | `boolean` | Table grouped by a date interval: the dates become the columns (every bucket in the range, oldest first) and the `segmentBy` values the rows, with a Total column and a Total row for count and sum. With no `segmentBy`, one row named by its measure. |
193
+ | `higherIsBetter` | `boolean` | Dates-across table: colours the Change column (last complete period vs the one before) green for good and red for bad. Unset leaves it grey. |
194
+ | `highlights` | `{ row, colour?, bold? }[]` | Table rows marked by label: tint (`red` `amber` `green` `blue` `purple`) and/or bold. Set by an editor from the highlight bar beside a row label (`onHighlightRow`). |
195
+ | `valueDisplay` | `"count" \| "percent" \| "both"` | Dates-across table split by `segmentBy`: cells as counts, as each period's share of its total, or both. In `percent`, Change is in percentage points. |
196
+ | `recordColumns` / `recordSort` / `recordPageSize` | `string[]` / `Sort` / `number` | What clicking a value opens: the columns in order, the sort and the rows per page. Unset follows the datasheet. ChartView's `renderDrillDown` lets an app render that view itself. |
197
+ | `rangeOverride` | `{ startDate?: string; endDate: string }` | ISO range set by the page the chart sits on; replaces the chart's own range and hides its picker. No `startDate` counts all time, and date buckets then start at the first record's day. Not saved with the chart. |
192
198
 
193
199
  ##### WorkspaceDevSettings
194
200
 
@@ -744,13 +744,29 @@ A `type: "formula"` column is a monday.com-style formula. It is worked out from
744
744
 
745
745
  - **Syntax** follows [monday's formula column](https://support.monday.com/hc/en-us/articles/360001235445-The-Formula-Column): `{Column}` references a column (the builder shows names, the schema stores ids, so renaming a column never breaks a formula), text in quotes, `=` / `<>` compare, `&` joins text (below `+`/`-`, above comparisons, as in monday), `+ - * / ^ %` and `( )`.
746
746
  - **Functions** — monday's library, in `src/components/data/formula/functions.ts`: text (`CONCATENATE`, `LEFT`, `LEN`, `REPLACE`, `SEARCH`, `SUBSTITUTE`, `TEXT`, `TRIM` …), logical (`IF`, `SWITCH`, `AND`, `OR`, `XOR`, `NOT`, `EXACT`), numeric (`SUM`, `AVERAGE`, `ROUND`, `ROUNDUP`, `ROUNDDOWN`, `MOD`, `POWER` …), date (`TODAY`, `NOW`, `DAYS`, `WORKDAYS`, `ADD_DAYS`, `FORMAT_DATE`, `WEEKNUM` …) and `PI` / `TRUE` / `FALSE`. Text results are capped at 10,000 characters.
747
- - **Values:** an empty cell is blank text, 0 in arithmetic and equal to 0 in a comparison with a number. Reference and role columns read their label; a math column reads the number it shows. A formula can read another formula; a circular reference shows `Error`. Files, images, addresses, objects, passwords, signatures, canvas and gantt columns cannot be used.
747
+ - **More functions:** `MID`, `FIND`, `VALUE`, `REGEXEXTRACT` (text), `ISBLANK`, `ISNUMBER` (logical), `INT` (numeric), `WEEKDAY`, `YEARFRAC` (date). `REGEXEXTRACT` returns the first match, or its first group; a pattern over 200 characters, one with a backreference or a nested quantifier such as `(a+)+`, and one that once ran longer than 50 ms are refused with an error instead of run.
748
+ - **Values:** an empty cell is blank text, 0 in arithmetic and equal to 0 in a comparison with a number. Reference and role columns read their label; a math column reads the number it shows. A time tracking column reads in hours; a date range reads as `start - end`, and `{Timeline#Start}` / `{Timeline#End}` read one end as a date. A formula can read another formula; a circular reference shows `Error`. Files, images, addresses, objects, passwords, signatures, canvas and gantt columns cannot be used.
748
749
  - **Adding:** with `bareAddColumns`, pick **Formula** as the type in the header + panel. The builder lists columns and functions with an example of each, validates as you type and previews the first row.
749
750
  - **Sort and filter** use the computed value, as monday does: header / view settings sort, and the flex filters. Either makes the list read the matching rows — at most `FORMULA_VIEW_MAX_ROWS` (5,000), in a fixed order, with a notice when there are more — and filter, order and page them locally. The cap also applies to the calendar, map, timeline and legacy grouped views, which otherwise read every row. On Firebase and union lists formula columns are not offered for sorting or filtering. CSV export sends the rows a formula filter kept, by id; "Select all pages" is off in a formula view, and the selection callbacks' query does not carry formula conditions.
750
751
  - **Earlier library versions** have no case for `formula`, so they show its cell blank. Apps that build schemas with `parseSpaceToDataStructure` need this version to receive `formulaExpression`; a consumer with its own `DataType` list (blixify-space-next `models/Data.ts`) adds `"formula"` to it.
751
752
 
752
753
  ---
753
754
 
755
+ ### Button, time tracking, date range, rating and system columns
756
+
757
+ Column types with monday.com counterparts. The value maths is in `src/components/data/columnTypes/`, as pure functions a server can import.
758
+
759
+ | Column | Schema | Stored value | Where it shows |
760
+ | --- | --- | --- | --- |
761
+ | Button | `type: "button"`, `buttonLabel?`, `buttonColour?`, `buttonConfirm?` | nothing | table, list, kanban and read view; never in a form. A tap calls `organise.onButtonClick(columnId, row)` and the button stays busy until the returned promise settles, ignoring taps meanwhile. `buttonConfirm` asks first. Disabled without `onButtonClick` or update rights |
762
+ | Time tracking | `type: "timeTracking"` | `{ totalSeconds, running, startedAt?, sessions? }` | table and read view show the total with start / stop, ticking each second inside the cell only; a form shows the total read-only. Start / stop saves that one field. `handleStartTimeTracking` / `handleStopTimeTracking` are idempotent, so a flow can drive the same value. Sorts on `<id>.totalSeconds`; formulas read hours (`handleGetTimeTrackingHours`) |
763
+ | Date range | `type: "dateRange"`, `minDate?`, `maxDate?` | `{ start, end }` | `12 – 18 Nov`; a range picker in the form; filters and sorts on `<id>.start`. A calendar or timeline tab can use `<id>.start` and `<id>.end` as its dates |
764
+ | Rating | `type: "number"`, `ratingMax` | a number | stars in every view; a tap saves the number, tapping the current rating clears it |
765
+
766
+ `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).
767
+
768
+ ---
769
+
754
770
  ### With activity log (comments + audit trail)
755
771
 
756
772
  Enable `edit.activityLog` to open the activity panel from list and read views, in a drawer on the right (a bottom sheet on phones). In the new table each record also gets a chat bubble beside its title, for anyone who can see the row. It uses the new feeds and pagination UI, an inline comment composer, editable comments, confirmed deletion, and before/after audit values. It follows `organise.darkMode`, including when opened from a legacy table.
@@ -1193,7 +1209,8 @@ Which field types can be edited from the list table, and how. Types not listed u
1193
1209
  | `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` |
1194
1210
  | `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` |
1195
1211
  | `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` |
1196
- | `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 |
1212
+ | `groupBy` | `string` | Column ID to group list results by. With `bareNewDTW` the groups are bands in one table. Grouped by a `list` or `boolean` column, every option (in option order, with its colour) plus "Unknown" (a value outside the options) and "No Value" (missing or blank) 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 (a `person` column's sections follow the member order), page mode groups the rows of the current page and scroll mode keeps the normal scroll; a section built from loaded rows draws 50 rows and a "Show more" at a time. A search, filter, sort or regroup starts every band over. Firebase and `bareListMobileView` keep the normal pagination. Saved views store and restore it |
1213
+ | `onButtonClick` | `(columnId: string, row: any) => Promise<any> \| void` | Called when a `button` column is tapped, with the stored record. The button is busy until the promise settles. See Button, time tracking … columns |
1197
1214
  | `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 |
1198
1215
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
1199
1216
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
@@ -1309,6 +1326,7 @@ Injects custom React components and callbacks into the list, read, and update vi
1309
1326
  | `bareListMobileFloatingSearch` | `boolean` | Floating search bar at the bottom on phones; the list reserves room under its last row for it |
1310
1327
  | `bareMobileTooltip` | `MobileViewTooltip` | Passed to NewTable as `mobileTooltip` when `bareNewDTW` and `bareListMobileView` are on: a Tag, trailing text and summary line on the collapsed row, plus a hover card. See the NewTable README → Mobile tooltip |
1311
1328
  | `bareListMobileTooltip` | `NewListMobileTooltip` | Passed to NewList when `bareNewDTW` is on: `imageKey`, `summaryKeys`, `tagKeys` and `tagColors` for the hover card on list items |
1329
+ | `bareSystemColumns` | `boolean` | Offers Created At / By, Updated At / By, Record ID and Number in the column settings, sort, filters and saved views without declaring them. See Button, time tracking … columns |
1312
1330
  | `bareCalendarOnClickEvent` | `(event: NewCalendarEvent, rawData?: any) => void` | Replaces the calendar's open-in-read-view on tap when `bareNewDTW` is on; `rawData` is the tapped event's unformatted row. See Calendar view |
1313
1331
  | `bareCalendarMapEvents` | `(events: NewCalendarEvent[]) => NewCalendarEvent[]` | Reshapes the `bareNewDTW` calendar's events after each fetch — expand one row into several, or restyle; each event's row is in `meta.rawData`. See Calendar view |
1314
1332
  | `bareCalendarOnChangeRange` | `(start: Date, end: Date, view) => void` | Fired when the user pages the `bareNewDTW` calendar or switches its view, with the range now on screen. Never fires on render. See Calendar view |
@@ -1160,7 +1160,7 @@ const GROUPBY_SCHEMA: DataStructure[] = [
1160
1160
 
1161
1161
  // INFO: 2400 rows in owner blocks (~400 each), so the first page holds only
1162
1162
  // Alice — every other band has to load on its own. Every 40th row has no owner
1163
- // and lands in "Unknown/No Value".
1163
+ // and lands in the No Value section.
1164
1164
  const renderGroupByRecords = () => {
1165
1165
  const rows: any[] = [];
1166
1166
  const total = 2400;
@@ -1206,14 +1206,20 @@ const handleInstallGroupByMock = (store: any[]) => {
1206
1206
  }
1207
1207
  };
1208
1208
 
1209
- // INFO: applies every "=" and "!in" condition — a band's own query and
1210
- // any filter the list carries — so a filter narrows each band's total
1209
+ // INFO: applies every "=", "in" and "!in" condition — a band's own query
1210
+ // and any filter the list carries — so a filter narrows each band's total.
1211
+ // A null in a list matches a missing field too, as Mongo's $in does.
1212
+ const handleIsIn = (list: any[], value: any) =>
1213
+ list.some((each) =>
1214
+ each === null ? value === null || value === undefined : each === value,
1215
+ );
1211
1216
  const handleMatchQuery = (row: any, query: any[]) =>
1212
1217
  (Array.isArray(query) ? query : []).every((eachQuery) => {
1213
1218
  if (!eachQuery?.queryId) return true;
1214
1219
  const value = row[eachQuery.queryId];
1215
1220
  if (eachQuery.type === "=") return value === eachQuery.value;
1216
- if (eachQuery.type === "!in") return !eachQuery.value.includes(value);
1221
+ if (eachQuery.type === "in") return handleIsIn(eachQuery.value, value);
1222
+ if (eachQuery.type === "!in") return !handleIsIn(eachQuery.value, value);
1217
1223
  return true;
1218
1224
  });
1219
1225
 
@@ -1337,7 +1343,7 @@ export const GroupByScrollPagination = () => (
1337
1343
  <div className="text-sm mb-4 text-gray-600">
1338
1344
  2,400 items in owner blocks (~400 each, every 40th with no owner), all
1339
1345
  from local mock data. The board opens grouped by <code>Owner</code>; the
1340
- first page holds only Alice, yet every owner and Unknown/No Value is a
1346
+ first page holds only Alice, yet every owner, Unknown and No Value is a
1341
1347
  band from the start and loads its own rows. To demo: keep the{" "}
1342
1348
  <strong>Group</strong> picker on Owner (or switch it to Status), then
1343
1349
  scroll down inside a band — that group fetches its next page on its own,
@@ -79,7 +79,7 @@ Async / database search:
79
79
  | `disableKeyboard` | `boolean` | `false` | Disable keyboard navigation in the dropdown |
80
80
  | `showTag` | `boolean` | — | Show selected values as removable tags below the input (multi select only) |
81
81
  | `closeOnClickOutside` | `boolean` | — | Keep the dropdown open until the user clicks outside. Multi select only |
82
- | `mobileView` | `boolean` | — | Open a bottom-sheet modal on screens narrower than 768px instead of the inline dropdown |
82
+ | `mobileView` | `boolean` | — | Open a bottom-sheet modal on screens narrower than 768px instead of the inline dropdown. With an array `value` the sheet stays open after each pick and shows a checkbox per option |
83
83
  | `portalDropdown` | `boolean` | — | Render the dropdown via a React portal (fixed position). Useful inside overflow-hidden containers |
84
84
  | `portalDropdownScrollOffset` | `boolean` | `true` | Only applies when `portalDropdown` is true. When `true` (default), adds `window.scrollY/X` to the dropdown position — correct for normal page usage. Set to `false` inside tables or overflow containers where `getBoundingClientRect()` already returns viewport-relative coords (e.g. FlexTable hardcodes this to `false` automatically) |
85
85
  | `staticDropdown` | `boolean` | — | Render the dropdown in static position (takes up space below the input) instead of absolute |
@@ -53,6 +53,7 @@ const genderOptions: Option[] = [
53
53
  export const Select = () => {
54
54
  const [value, setValue] = useState("");
55
55
  const [multiValue, setMultiValue] = useState<string[]>(["M"]);
56
+ const [mobileMultiValue, setMobileMultiValue] = useState<string[]>([]);
56
57
  const [dbValue, setDBValue] = useState("");
57
58
  const [dbOptions, setDBOptions] = useState<Option[]>([
58
59
  { key: "M", label: "Male", secondaryLabel: "Secondary Label" },
@@ -208,7 +209,7 @@ export const Select = () => {
208
209
 
209
210
  <StorySection
210
211
  title="Mobile View"
211
- description="mobileView=true opens a bottom-sheet modal on screens narrower than 768px instead of the inline dropdown."
212
+ description="mobileView=true opens a bottom-sheet modal on screens narrower than 768px instead of the inline dropdown. A multi-select sheet stays open and shows a checkbox per option."
212
213
  >
213
214
  <div className="w-full md:w-1/2">
214
215
  <SelectImpl
@@ -219,6 +220,16 @@ export const Select = () => {
219
220
  onChange={(v: string) => setValue(v)}
220
221
  />
221
222
  </div>
223
+ <div className="mt-4 w-full md:w-1/2">
224
+ <SelectImpl
225
+ value={mobileMultiValue}
226
+ label="Mobile View Multi"
227
+ options={genderOptions}
228
+ mobileView
229
+ showTag
230
+ onChange={(v: string[]) => setMobileMultiValue(v)}
231
+ />
232
+ </div>
222
233
  </StorySection>
223
234
 
224
235
  <StorySection
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {