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.
@@ -103,6 +103,7 @@ Custom styled with annotation labels:
103
103
  | `stackedVertical` | `boolean` | `false` | Stack series on top of each other (requires `stackedBarSeries`) |
104
104
  | `distributed` | `boolean` | `false` | Give each bar in a single series a distinct colour |
105
105
  | `showDataLabel` | `boolean` | `true` | Show value labels on bars |
106
+ | `dataLabelPosition` | `"inside" \| "outside"` | — | Where each bar's value is printed: inside the bar, or outside past its end (above an upright bar, beside a horizontal one) in dark text. Unset, it is outside on a chart with `benchmarkLines` and inside otherwise. A stacked chart always keeps its values inside |
106
107
  | `showToolbar` | `boolean` | `false` | Show the ApexCharts toolbar |
107
108
  | `borderColor` | `string` | `"#e7e7e7"` | Grid line colour. Pass `""` to remove grid lines |
108
109
  | `newTabUrl` | `string` | `""` | Makes the chart title a link that opens in a new tab |
@@ -119,6 +120,7 @@ Custom styled with annotation labels:
119
120
  | `roundedBars` | `boolean` | `false` | Apply rounded corners to bar tops |
120
121
  | `disableZoom` | `boolean` | `false` | Disable zoom interaction |
121
122
  | `disableTooltip` | `boolean` | `false` | Disable hover tooltips |
123
+ | `benchmarkLines` | `{ value, label?, color? }[]` | — | Dashed benchmark lines across the chart, each with its label (or its value, for one without a label) and colour (default purple); hovering a label shows both. Placed like monday.com: just above the line at its left end on an upright bar, and along the top just right of the line on a horizontal one (left of it at the right edge). Close labels move apart, and none covers a bar value: with benchmark lines the bars show their values past their ends. Past 3 rows, a horizontal label shows as "…". The value axis grows to show a benchmark above every bar |
122
124
  | `customAnimation` | `AnimationConfig` | — | Override default animation settings |
123
125
  | `xAxisLabels` | `AxisLabelConfig` | — | Customise X-axis label rotation, style, and formatter |
124
126
  | `yAxisLabels` | `AxisLabelConfig` | — | Customise Y-axis label rotation, style, and formatter |
@@ -8,13 +8,20 @@ import { StorySection } from "../../storyUtils";
8
8
  const meta = { title: "Chart/Bar Chart" };
9
9
  export default meta;
10
10
 
11
-
12
11
  const stackedSeries = [
13
12
  { name: "Company A", data: [400, 430, 448] },
14
13
  { name: "Company B", data: [430, 460, 478] },
15
14
  { name: "Company C", data: [448, 470, 490] },
16
15
  ];
17
16
 
17
+ const benchmarkLines = [
18
+ { value: 15, label: "April SF Target", color: "#8B5CF6" },
19
+ { value: 30, label: "May SF Target", color: "#8B5CF6" },
20
+ { value: 45, label: "June SF Target", color: "#8B5CF6" },
21
+ { value: 3, label: "April LF Target", color: "#EF4444" },
22
+ { value: 6, label: "June LF Target", color: "#EF4444" },
23
+ ];
24
+
18
25
  const rawData = [
19
26
  { label: "Apple", value: 10, daysAgo: 7 },
20
27
  { label: "Mango", value: 20, daysAgo: 7 },
@@ -50,7 +57,7 @@ export const BarChart = () => {
50
57
  .map((d) => ({ x: d.label, y: d.value }));
51
58
  setSeriesData(points);
52
59
  },
53
- [chartDummyData]
60
+ [chartDummyData],
54
61
  );
55
62
 
56
63
  useEffect(() => {
@@ -95,6 +102,82 @@ export const BarChart = () => {
95
102
  />
96
103
  </StorySection>
97
104
 
105
+ <StorySection
106
+ title="Values Outside The Bars"
107
+ description={
108
+ <>
109
+ <code>dataLabelPosition="outside"</code> prints each bar's value
110
+ past its end: above an upright bar, beside a horizontal one. Charts
111
+ with <code>benchmarkLines</code> do this on their own.
112
+ </>
113
+ }
114
+ >
115
+ <BarChartImpl
116
+ title="Videos Done by Tier"
117
+ seriesName="Videos"
118
+ orientation="col"
119
+ distributed
120
+ dataLabelPosition="outside"
121
+ categories={["Tier 3", "Tier 2", "Tier 1"]}
122
+ seriesData={[
123
+ { x: "Tier 3", y: 8 },
124
+ { x: "Tier 2", y: 12 },
125
+ { x: "Tier 1", y: 33 },
126
+ ]}
127
+ />
128
+ </StorySection>
129
+
130
+ <StorySection
131
+ title="Benchmark Lines"
132
+ description={
133
+ <>
134
+ <code>benchmarkLines</code> draws a dashed line across the chart at
135
+ each goal, with its label, in its <code>color</code>. The value axis
136
+ grows to show a goal above every bar, and a horizontal bar draws its
137
+ goals upright.
138
+ </>
139
+ }
140
+ >
141
+ <BarChartImpl
142
+ title="Videos Done by Type"
143
+ seriesName="Videos"
144
+ orientation="col"
145
+ distributed
146
+ categories={[
147
+ "Poster (Static)",
148
+ "Photo",
149
+ "Video (Long)",
150
+ "Video (short)",
151
+ ]}
152
+ seriesData={[
153
+ { x: "Poster (Static)", y: 3 },
154
+ { x: "Photo", y: 4 },
155
+ { x: "Video (Long)", y: 17 },
156
+ { x: "Video (short)", y: 57 },
157
+ ]}
158
+ benchmarkLines={benchmarkLines}
159
+ />
160
+ <div className="mt-6">
161
+ <BarChartImpl
162
+ title="Videos Done by Type (horizontal, close goals stack)"
163
+ seriesName="Videos"
164
+ orientation="row"
165
+ categories={["Poster (Static)", "Photo", "Video (Long)"]}
166
+ seriesData={[
167
+ { x: "Poster (Static)", y: 3 },
168
+ { x: "Photo", y: 4 },
169
+ { x: "Video (Long)", y: 17 },
170
+ ]}
171
+ benchmarkLines={[
172
+ { value: 30, label: "May SF Target", color: "#8B5CF6" },
173
+ { value: 4, label: "April LF Target", color: "#EF4444" },
174
+ { value: 5, label: "May LF Target", color: "#EF4444" },
175
+ { value: 6, label: "June LF Target", color: "#10B981" },
176
+ ]}
177
+ />
178
+ </div>
179
+ </StorySection>
180
+
98
181
  <StorySection
99
182
  title="Stacked Vertical Bar Chart"
100
183
  description={
@@ -309,4 +392,4 @@ export const BarChart = () => {
309
392
  </StorySection>
310
393
  </div>
311
394
  );
312
- }
395
+ };
@@ -83,8 +83,10 @@ Chart only (no header or date range):
83
83
  | `type` | `"pie" \| "donut"` | `"donut"` | Chart type |
84
84
  | `valueType` | `"percent" \| "value"` | `"percent"` | Whether labels and data labels show percentages or raw values |
85
85
  | `roundValue` | `boolean` | `false` | Round displayed values to whole numbers |
86
- | `legendPos` | `"top" \| "bottom" \| "right" \| "left"` | `"top"` | Position of the legend |
86
+ | `legendPos` | `"top" \| "bottom" \| "right" \| "left"` | `"top"` | Position of the legend. Left and Right move below, centred, while the chart is narrower than 360px (`PIE_SIDE_LEGEND_MIN_WIDTH`), and return beside it from 384px |
87
87
  | `legendAlign` | `"right" \| "left" \| "center"` | `"right"` | Horizontal alignment of the legend |
88
+ | `showTotal` | `boolean` | `false` | Show the sum of the series in the centre of a donut. Ignored for `type="pie"`. Selecting a slice shows its name and value there instead |
89
+ | `showDataLabel` | `boolean` | `true` | Draw each slice's value on the pie. The legend and tooltip still show it when `false` |
88
90
  | `newTabUrl` | `string` | `""` | Makes the chart title a link that opens in a new tab |
89
91
 
90
92
  > Note: Legend labels are truncated to 10 characters. The full label is shown in the tooltip on hover.
@@ -137,9 +137,25 @@ export const PieChart = () => {
137
137
  </div>
138
138
  </StorySection>
139
139
 
140
+ <StorySection
141
+ title="Total And Slice Values"
142
+ description='showTotal=true puts the sum in the centre of a donut, its "Total" caption smaller and lighter than the number. showDataLabel=false hides the value on each slice; the legend and tooltip still show it.'
143
+ >
144
+ <div className="w-full">
145
+ <PieChartImpl
146
+ title="Orders By Channel"
147
+ series={series}
148
+ labels={labels}
149
+ showTotal
150
+ showDataLabel={false}
151
+ roundValue={true}
152
+ />
153
+ </div>
154
+ </StorySection>
155
+
140
156
  <StorySection
141
157
  title="Dark Mode"
142
- description="Pass darkMode=true and optionally darkModeBg to customise the background."
158
+ description="Pass darkMode=true and optionally darkModeBg to customise the background. The total in the centre turns white, with a lighter grey caption."
143
159
  dark
144
160
  >
145
161
  <div className="w-full">
@@ -150,6 +166,7 @@ export const PieChart = () => {
150
166
  tool={<PencilIcon className="h-4 w-4 text-primary-600" />}
151
167
  darkMode={true}
152
168
  darkModeBg="bg-gradient-to-r from-slate-700 to-gray-900"
169
+ showTotal
153
170
  roundValue={true}
154
171
  />
155
172
  </div>
@@ -177,6 +177,9 @@ Custom layout via renderSettings:
177
177
  | `cardIcon` | `string` | Heroicon name (solid) for the card icon, e.g. `"UserIcon"`. Defaults to `FolderIcon`. |
178
178
  | `cardSize` | `"default" \| "compact"` | `card` layout; `"compact"` is the short stat-first card. Defaults to `"default"`. |
179
179
  | `pieType` | `"donut" \| "pie"` | `pie` style. Defaults to `"donut"`. |
180
+ | `legendPosition` | `"top" \| "bottom" \| "left" \| "right"` | `pie` legend placement. Defaults to `"top"`. |
181
+ | `showDataLabel` | `boolean` | `pie` only: `false` hides the value on each slice; the legend and tooltip still show it. Defaults to shown. |
182
+ | `showTotal` | `boolean` | `pie` donut only: the total in the centre. Ignored for averages. Defaults to hidden. |
180
183
  | `cardEmoji` | `string` | Emoji shown instead of `cardIcon` when set, e.g. `"📦"`. Clearing it brings `cardIcon` back. |
181
184
  | `cardIconBgColor` | `string` | Tailwind bg class for the card icon background |
182
185
  | `colors` | `string[]` | Custom color array for chart segments/bars/lines |
@@ -187,15 +190,29 @@ Custom layout via renderSettings:
187
190
  | `segmentBy` | `string` | Second column split into stacked series (bar, line, table), at most 10 values plus "Others". |
188
191
  | `sortBy` | `"value" \| "label"` | Sort the groups. `sortDirection` is `"asc"` or `"desc"` (default). |
189
192
  | `topN` | `number` | Keep the first N groups; the rest are added up as "Others" unless `showOthers` is `false` (averages never get "Others"). |
190
- | `showPercent` | `boolean` | Show each group as a percentage of the total (count and sum, unsplit charts). |
193
+ | `showPercent` | `boolean` | Show each group as a percentage of the total (count and sum, unsplit charts). Ignored for `pie`, which already draws slices as shares. |
191
194
  | `cumulative` | `boolean` | Running total over the date buckets. |
192
195
  | `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
196
  | `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. |
197
+ | `benchmarks` | `{ label?, value, colour? }[]` | `bar` only: goals drawn as dashed lines across the chart, each with its label. `colour` is `purple` (default) `red` `amber` `green` `blue`, the same five as `highlights`, each with a lighter shade ready for dark mode (`handleChartBenchmarkLines(chart, darkMode)`); DataGraph's cards are light today, so it uses the light one. Picked from a row of swatches in the builder's Benchmark lines section. |
198
+ | `valuePosition` | `"inside" \| "outside" \| "hidden"` | `bar` only: where the values are printed, or `"hidden"` for none. Unset is inside; with `benchmarks` it is outside, and inside counts as outside. A chart split by a column (`segmentBy`) has no benchmark lines and keeps its values inside or hidden. Set in the builder's Values position dropdown. |
194
199
  | `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
200
  | `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
201
  | `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
202
  | `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. |
198
203
 
204
+ ##### Chart builder sections
205
+
206
+ ChartView's Edit Chart form groups these settings into collapsible sections, like monday.com's Widget settings. A header opens or closes its section, and a closed one keeps its fields in the form and shows a one-line summary.
207
+
208
+ | Section | Opens by default | Fields | Summary when closed |
209
+ | --------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------ | ------------------------------------ |
210
+ | Chart | Yes | Datasheet, Title, type, Emoji (card), Orientation / Style, Width | The chart type |
211
+ | Data | Only when a setting is missing | Measure, Of column, Group By, Date Range On / Over Time, Interval, Split By | e.g. "Sum of Amount by Status", or what's missing in red, such as "Pick a column to sum" |
212
+ | Customize | No | Values position, Legend, Show values / Show total, Sort / Show, Show as %, Multicolour bars, the table options, Running total. Hidden when the chart has none of these, such as a card | — |
213
+ | Benchmark lines | No | `bar` only: the goal lines. A chart split by a column says why it has none | e.g. "2 lines", "None" |
214
+ | Records list | No | Columns, Sort by, Order, Rows per page | e.g. "20 rows per page, sorted by Amount" |
215
+
199
216
  ##### WorkspaceDevSettings
200
217
 
201
218
  | Name | Type | Description |
@@ -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
@@ -750,6 +750,8 @@ A `type: "formula"` column is a monday.com-style formula. It is worked out from
750
750
  - **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.
751
751
  - **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.
752
752
  - **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.
753
+ - **On a server**, wrap evaluation in `handleWithFormulaTimezone(timezone, () => …)` so `TODAY()`, `NOW()` and stored dates are read on the reader's calendar rather than the server's (UTC). Inside it, a `Date` or zoned text (`…Z`, `…+08:00`) becomes that timezone's wall-clock time; text without a zone is read as written. An invalid or missing timezone changes nothing, and the browser needs no wrapper.
754
+ - **Charts** offer formula columns to group by (bar, pie, table) and to sum or average (`handleGetMeasureOptions`). Their values are never stored, so the server answering the chart works them out per row; Space does this.
753
755
  - **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.
754
756
 
755
757
  ---
@@ -764,6 +766,7 @@ Column types with monday.com counterparts. The value maths is in `src/components
764
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`) |
765
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 |
766
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 |
767
770
 
768
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).
769
772
 
@@ -1275,14 +1278,14 @@ Which field types can be edited from the list table, and how. Types not listed u
1275
1278
  | `addColumnEnabled` | `boolean` | Shows an add-column button in the table header |
1276
1279
  | `persistViewState` | `"persistent" \| "session"` | Remembers the list's sort, filters, hidden columns, page, rows per page, search and reading position per collection, so a Back navigation lands where the user left. `"persistent"` uses `localStorage`; `"session"` writes `sessionStorage` and reads it first, falling back to `localStorage`. The reading position is restored on mount and on returning from a record, not on a tab switch; with several lists on one page, only the list a record was last opened from restores (tracked in `blixify-dtw-persist-last-drill`). Covers table, list, grid, kanban and feeds; not map. List view only |
1277
1280
  | `persistKey` | `string` | Keeps a separate saved view state for this instance: the storage key becomes `blixify-dtw-persist-<collectionId>-<persistKey>`. Saved state is read once, on mount, so give the component a React `key` that changes with `persistKey` |
1278
- | `savedViews` | `boolean` | Shows a row of view tags under the tab bar: save the current filters, flex filters, sort, rows per page, grouping, columns and column widths under a name, reapply it, update it or delete it. Views belong to the tab they were saved on and are kept in `localStorage` under `blixify-dtw-views-<collectionId>[-<persistKey>][-<currentUser.userEmail>]`. Sort is held by the template while it is on, as with `persistViewState`, and applying a view calls `onChangeSort`, `onChangeGroupBy`, `onChangePagination(0)` and `bareListHandleFilter` when those change. Each write re-reads storage first, so views saved in another browser tab are kept. With `onChangeHide` / `onChangeDrag`, a view's hides and column order stay on screen only: a toggle or reorder after applying one writes back the schema's columns plus that single change |
1281
+ | `savedViews` | `boolean` | Shows a row of view tags under the tab bar: save the current filters, flex filters, sort, rows per page, grouping, columns and column widths under a name, reapply it, update, rename or delete it. Past eight views the rest fold behind “+N more”; the open view always shows. Views belong to the tab they were saved on and are kept in `localStorage` under `blixify-dtw-views-<collectionId>[-<persistKey>][-<currentUser.userEmail>]`. Sort is held by the template while it is on, as with `persistViewState`, and applying a view calls `onChangeSort`, `onChangeGroupBy`, `onChangePagination(0)` and `bareListHandleFilter` when those change. Each write re-reads storage first, so views saved in another browser tab are kept. With `onChangeHide` / `onChangeDrag`, a view's hides and column order stay on screen only: a toggle or reorder after applying one writes back the schema's columns plus that single change |
1279
1282
  | `defaultSavedViews` | `DefaultSavedView[]` | Views provided by the app (`{ id?, name, tabId?, state }`), listed before the user's own and never deleted. Without `tabId` a view is listed on every tab. A field left out of `state` is reset to its default when the view is applied. `id` must be unique; without one the view gets `default-<index>`, its position in the whole array |
1280
1283
  | `sharedViews` | `SharedSavedView[]` | Views the app stores for everyone (`{ id, name, tabId, state }`), e.g. on the record the list belongs to. Given, it replaces this browser's storage: the list shows only these, saving goes to `onChangeSharedViews`, and nothing is read from or written to `localStorage` |
1281
1284
  | `onChangeSharedViews` | `(views: SharedSavedView[]) => void` | Called with the whole list after a view is saved, updated or deleted, for the app to store and pass back as `sharedViews`; a second change before it comes back builds on the list just sent. The whole list is replaced each time, so two editors saving at once is last-writer-wins. Without it the app's views are read-only and “+ Save view” is hidden |
1282
1285
  | `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` |
1283
1286
  | `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` |
1284
1287
  | `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` |
1285
- | `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 |
1288
+ | `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, one band at a time, and a band not read yet shows the grouped count's total (a band it counts empty stays hidden until its own read), 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 |
1286
1289
  | `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 |
1287
1290
  | `onTimeTrackingAction` | `(columnId: string, row: any, action: "start" \| "stop") => Promise<any>` | Starts or stops a `timeTracking` timer on the host's server, with the stored record. Resolve with the value the server stored; the timer shows it, and goes back to where it was if the promise rejects. Without it a timer works its value out on the device and saves it through the single-field update |
1288
1291
  | `allView` | `PersistedViewState` | All's settings as the app stores them for everyone (`{}` when none are saved yet): sort, grouping, filters, hidden columns, column order, column widths (`columnWidths`, `"180px"` by column id) and rows per page. A setting left out falls back to the model and the props. Needs `savedViews`. Given, the list opens on it, and every change a person makes to All or a saved view is kept in their browser (`blixify-dtw-draft-<collectionId>[-<persistKey>][-<currentUser.userEmail>]-<all \| view id>`) until they choose Update or Reset; a saved view with no width of its own for a column follows All's |
@@ -0,0 +1,311 @@
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 as DataTemplateImpl } from ".";
6
+ import { SharedSavedView } from "./dataStateModel";
7
+ import { DataStructure } from "./model";
8
+
9
+ const meta = { title: "Data/Data Template" };
10
+ export default meta;
11
+
12
+ const ENDPOINT = "https://grouped-board.local/api";
13
+ const COLLECTION = "grouped-board";
14
+
15
+ // INFO: the group sizes of a real 148,936-row leads board grouped by state —
16
+ // a few very large groups, many small ones, blanks and stray spellings that
17
+ // fall under Unknown
18
+ const STATE_SIZES: [string | null, number][] = [
19
+ ["Selangor", 63116],
20
+ ["Johor", 18340],
21
+ ["Kuala Lumpur", 14488],
22
+ ["Penang", 11831],
23
+ ["Perak", 9141],
24
+ ["Negeri Sembilan", 7690],
25
+ ["Kedah", 6460],
26
+ ["Melaka", 6119],
27
+ ["", 5014],
28
+ ["Pahang", 2226],
29
+ [null, 2108],
30
+ ["Perlis", 904],
31
+ ["Kelantan", 533],
32
+ ["Terengganu", 409],
33
+ ["Kuantan", 221],
34
+ ["Putrajaya", 182],
35
+ ["Sabah", 38],
36
+ ["Sarawak", 32],
37
+ ["Puchong", 16],
38
+ ["Pulau Pinang", 12],
39
+ ["Pahang (Kuantan)", 11],
40
+ ["Kedah (Exclude Langkawi)", 9],
41
+ ["Cyberjaya", 9],
42
+ ["KL", 8],
43
+ ["Negri Sembilan", 7],
44
+ ["Pahang (non-Kuantan)", 7],
45
+ ["Ipoh", 3],
46
+ ["Labuan", 1],
47
+ ];
48
+
49
+ const STATES = STATE_SIZES.map(([state]) => state).filter(
50
+ (state): state is string => !!state,
51
+ );
52
+ const STATUSES = ["New Lead", "Contacted", "Site Survey", "Closed", "Dropped"];
53
+
54
+ const SCHEMA: DataStructure[] = [
55
+ { id: "name", name: "Name", type: "string" },
56
+ { id: "area", name: "State (Area)", type: "list", listSelection: STATES },
57
+ { id: "status", name: "Status", type: "list", listSelection: STATUSES },
58
+ { id: "createdAt", name: "Created At", type: "date" },
59
+ ];
60
+
61
+ // INFO: rows interleaved across states, so the first page of the whole list
62
+ // holds a few states and one Pahang row — every other band has to load itself
63
+ const handleBuildRows = () => {
64
+ const pools = STATE_SIZES.map(([state, size]) => ({ state, left: size }));
65
+ const total = pools.reduce((sum, pool) => sum + pool.left, 0);
66
+ const rows: any[] = [];
67
+ let index = 0;
68
+ while (rows.length < total) {
69
+ const pool = pools[index % pools.length];
70
+ index++;
71
+ if (pool.left === 0) continue;
72
+ pool.left--;
73
+ const number = String(rows.length + 1).padStart(6, "0");
74
+ rows.push({
75
+ _id: `lead-${number}`,
76
+ name: `Load Test Lead ${number}`,
77
+ ...(pool.state === null ? {} : { area: pool.state }),
78
+ status: STATUSES[rows.length % STATUSES.length],
79
+ createdAt: new Date(2025, 0, 1, 0, rows.length).toISOString(),
80
+ });
81
+ }
82
+ return rows;
83
+ };
84
+
85
+ const handleIsIn = (list: any[], value: any) =>
86
+ list.some((each) =>
87
+ each === null ? value === null || value === undefined : each === value,
88
+ );
89
+
90
+ const handleMatch = (row: any, query: any[]) =>
91
+ (Array.isArray(query) ? query : []).every((each) => {
92
+ if (!each?.queryId) return true;
93
+ const value = row[each.queryId];
94
+ if (each.type === "=") return value === each.value;
95
+ if (each.type === "in") return handleIsIn(each.value, value);
96
+ if (each.type === "!in") return !handleIsIn(each.value, value);
97
+ return true;
98
+ });
99
+
100
+ // INFO: answers band reads, the whole-list read and the grouped count the way
101
+ // the list endpoint does, after a short delay so loading can be watched
102
+ const handleInstallMock = (rows: any[]) => {
103
+ const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
104
+ // INFO: e2e reads how many band reads were ever in flight at once
105
+ const tracker = ((window as any).__groupedBoardReads = {
106
+ inFlight: 0,
107
+ maxInFlight: 0,
108
+ });
109
+ // INFO: e2e reads how many table rows were drawn
110
+ (window as any).__newTableRowDraws = { count: 0 };
111
+ const handleDelay = () => new Promise((resolve) => setTimeout(resolve, 400));
112
+ mock.onPost(`${ENDPOINT}/${COLLECTION}/list`).reply(async (config) => {
113
+ const body = JSON.parse(config.data ?? "{}");
114
+ const isBandRead =
115
+ !body.aggregate &&
116
+ (body.query ?? []).some((each: any) => each?.queryId === "area");
117
+ if (isBandRead) {
118
+ tracker.inFlight += 1;
119
+ tracker.maxInFlight = Math.max(tracker.maxInFlight, tracker.inFlight);
120
+ }
121
+ await handleDelay();
122
+ if (isBandRead) tracker.inFlight -= 1;
123
+ const matched = rows.filter((row) => handleMatch(row, body.query));
124
+ if (body.aggregate?.type === "count") {
125
+ const counts = new Map<any, number>();
126
+ matched.forEach((row) => {
127
+ const key = row[body.aggregate.groupId] ?? null;
128
+ counts.set(key, (counts.get(key) ?? 0) + 1);
129
+ });
130
+ return [
131
+ 200,
132
+ {
133
+ data: [
134
+ {
135
+ _id: "range",
136
+ value: Array.from(counts, ([key, value]) => ({
137
+ _id: key,
138
+ value,
139
+ })),
140
+ },
141
+ ],
142
+ },
143
+ ];
144
+ }
145
+ if (body.aggregate) return [200, { data: [] }];
146
+ const cursor = typeof body.cursor === "number" ? body.cursor : 0;
147
+ const limit = typeof body.limit === "number" ? body.limit : 50;
148
+ return [
149
+ 200,
150
+ { data: matched.slice(cursor, cursor + limit), count: matched.length },
151
+ ];
152
+ });
153
+ // INFO: a row typed into a band's "Add item" row joins the front of the list
154
+ mock.onPost(`${ENDPOINT}/${COLLECTION}/create`).reply((config) => {
155
+ const body = JSON.parse(config.data ?? "{}");
156
+ const row = { _id: `lead-new-${rows.length + 1}`, ...(body.data ?? {}) };
157
+ rows.unshift(row);
158
+ return [200, { data: row._id }];
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
+ });
166
+ mock.onPost(`${ENDPOINT}/${COLLECTION}/get`).reply((config) => {
167
+ const body = JSON.parse(config.data ?? "{}");
168
+ return [200, { data: rows.find((row) => row._id === body.id) ?? null }];
169
+ });
170
+ return mock;
171
+ };
172
+
173
+ const VIEW_NAMES = [
174
+ "Closed Case",
175
+ "Drawing",
176
+ "Hunting & Referral",
177
+ "Marketing",
178
+ "1.60 Nuqman's Lead Board · Closed Case",
179
+ "1.60 Nuqman's Lead Board · Drawing",
180
+ "1.60 Nuqman's Lead Board · Marketing",
181
+ "1.59 Mei Jun's Lead Board · Closed Case",
182
+ "1.59 Mei Jun's Lead Board · Drawing",
183
+ "1.59 Mei Jun's Lead Board · Marketing",
184
+ "1.49 Hazim Suhaimi's Lead Board · Closed Case",
185
+ "1.49 Hazim Suhaimi's Lead Board · FB & Google",
186
+ "1.49 Hazim Suhaimi's Lead Board · Drawing",
187
+ "1.42 Wang's Lead Board · Closed Case",
188
+ "1.42 Wang's Lead Board · FB & Google",
189
+ "1.42 Wang's Lead Board · Drawing",
190
+ ];
191
+
192
+ const GroupedBoardDemo = (props: { isPhone?: boolean }) => {
193
+ const rows = useMemo(() => handleBuildRows(), []);
194
+ const [openedId, setOpenedId] = useState("");
195
+ useMemo(() => handleInstallMock(rows), [rows]);
196
+ const [sharedViews, setSharedViews] = useState<SharedSavedView[]>(() => [
197
+ {
198
+ id: "closed-only",
199
+ name: "Closed only",
200
+ tabId: "general",
201
+ state: {
202
+ flexFilters: [
203
+ { id: "f1", columnId: "status", operator: "=", value: "Closed" },
204
+ ],
205
+ },
206
+ },
207
+ ...VIEW_NAMES.map((name, index) => ({
208
+ id: `view-${index}`,
209
+ name,
210
+ tabId: "general",
211
+ state: {},
212
+ })),
213
+ ]);
214
+
215
+ return (
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
+ </>
271
+ );
272
+ };
273
+
274
+ export const GroupedLargeBoard = () => (
275
+ <div className="w-full">
276
+ <div className="mb-1 font-semibold text-gray-900">
277
+ Data Template — a large board grouped by a list column
278
+ </div>
279
+ <div className="mb-4 text-sm text-gray-600">
280
+ 148,936 mock leads across 28 state values (blanks included), sized like a
281
+ real leads board, grouped by State in scroll mode. Each read waits 400 ms.
282
+ Scroll up and down: every band shows its full total from the start, bands
283
+ not read yet draw no "Add item" rows, and bands load one at a time. The 16
284
+ views fold behind "+N more"; the pencil on a view renames it, and "Closed
285
+ only" filters every band. Cells edit in place on hover, and only the rows
286
+ near the screen are drawn.
287
+ </div>
288
+ <GroupedBoardDemo />
289
+ </div>
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
+ };