blixify-ui-web-mcp 0.1.43 → 0.1.46

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.
@@ -119,11 +119,12 @@ Custom layout via renderSettings:
119
119
 
120
120
  | Name | Type | Description |
121
121
  | ------------------ | ----------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
122
- | `type` | `"card" \| "bar" \| "line" \| "pie" \| "radar"` | Chart type to render |
122
+ | `type` | `"card" \| "bar" \| "line" \| "pie" \| "radar" \| "table"` | Chart type to render. `table` lists each `query` entry with its value and, except for `avg`, a total row |
123
123
  | `title` | `string` | Chart title — also used as the key for internal state |
124
124
  | `spaceId` | `string` | The collection/space ID to query data from |
125
125
  | `gridSize` | `number` | Number of grid columns this chart spans |
126
126
  | `query` | `Query[]` | Data series definitions. Each query entry produces one data point or series segment. Pass `[]` for a single aggregate. |
127
+ | `seriesLabels` | `string[]` | One label per `query` entry, shown instead of `<queryId> = <value>` on `bar` and `pie` charts. |
127
128
  | `queryBaseType` | `"count" \| "sum" \| "avg" \| "value"` | Aggregate function. Defaults to `"count"`. |
128
129
  | `queryAttributeId` | `string[]` | Required when `queryBaseType` is `"sum"`, `"avg"`, or `"value"`. The field(s) to aggregate. For `radar`, each entry becomes a radar axis. |
129
130
  | `queryBaseDateId` | `string` | Field used for date range filtering. When set, a date range picker is shown on the chart. |
@@ -131,6 +132,9 @@ Custom layout via renderSettings:
131
132
  | `baseQuery` | `Query[]` | Static filter queries always applied alongside any dynamic filters. |
132
133
  | `description` | `string` | Subtitle shown on `card` type charts |
133
134
  | `cardIcon` | `string` | Heroicon name (solid) for the card icon, e.g. `"UserIcon"`. Defaults to `FolderIcon`. |
135
+ | `cardSize` | `"default" \| "compact"` | `card` layout; `"compact"` is the short stat-first card. Defaults to `"default"`. |
136
+ | `pieType` | `"donut" \| "pie"` | `pie` style. Defaults to `"donut"`. |
137
+ | `cardEmoji` | `string` | Emoji shown instead of `cardIcon` when set, e.g. `"📦"`. Clearing it brings `cardIcon` back. |
134
138
  | `cardIconBgColor` | `string` | Tailwind bg class for the card icon background |
135
139
  | `colors` | `string[]` | Custom color array for chart segments/bars/lines |
136
140
  | `orientation` | `"col" \| "row"` | Bar chart orientation. Defaults to `"row"`. |
@@ -625,6 +625,72 @@ Set `viewType="kanban"` on an `extendedTabs` entry and configure `kanbanSettings
625
625
 
626
626
  ---
627
627
 
628
+ ### Chart view
629
+
630
+ Set `viewType="chart"` on an `extendedTabs` entry and list its charts in `chartSettings`. Each entry is a `Charts` object drawn by `DataGraph`, so a tab can hold several charts and a template can hold several chart tabs. `spaceId` falls back to the template's `collectionId`, and the table's active filters are added to every chart's `baseQuery`.
631
+
632
+ Editors (`edit.allEditable`) get add, edit, remove and move on each chart; add inserts the new chart before the one it sits on, and an empty tab shows an `EmptyState` with an Add Chart button instead. Cards use the compact layout unless `cardSize` is set. A card's emoji button opens a Choose Emoji popup with suggestions and the full emoji picker, which loads only when opened. The choice is saved as `cardEmoji` and shown instead of `cardIcon`; Clear brings the icon back. The builder offers Card, Bar, Line, Pie, Radar and Table, with a Style choice for Bar (horizontal or vertical) and Pie (donut or pie). The builder offers only the columns a chart can use: number columns for Sum and Average, list columns for Group By, date columns for the time axis. Every change is reported through `organise.onChangeTabs`, the same as other tab changes, and nothing is written to browser storage. Without `onChangeTabs` the charts are read-only.
633
+
634
+ ```tsx
635
+ <DataTemplate
636
+ title="Orders"
637
+ description="Order overview"
638
+ type={type}
639
+ id={id}
640
+ collectionId="orders"
641
+ model={model}
642
+ localURLEndpoint="https://dashboard.example.com"
643
+ devSettings={devSettings}
644
+ lib={{ axios, Menu, Transition, navigate: null }}
645
+ edit={{ allEditable: true }}
646
+ organise={{
647
+ handleNavigate,
648
+ extendedTabs: tabs,
649
+ onChangeTabs: setTabs,
650
+ }}
651
+ />
652
+
653
+ // tabs
654
+ [
655
+ {
656
+ name: "Overview",
657
+ type: "list",
658
+ viewType: "chart",
659
+ chartSettings: [
660
+ { title: "Orders", type: "card", gridSize: 1, spaceId: "", query: [], queryBaseType: "count" },
661
+ {
662
+ title: "Orders By Status",
663
+ type: "pie",
664
+ gridSize: 2,
665
+ spaceId: "",
666
+ query: [
667
+ { type: "=", queryId: "status", value: "paid" },
668
+ { type: "=", queryId: "status", value: "shipped" },
669
+ ],
670
+ // one label per query entry
671
+ seriesLabels: ["Paid", "Shipped"],
672
+ queryBaseType: "count",
673
+ },
674
+ { title: "Orders Per Day", type: "line", gridSize: 2, spaceId: "", query: [], queryBaseType: "count", queryBaseDateId: "createdAt" },
675
+ ],
676
+ },
677
+ ]
678
+ ```
679
+
680
+ ---
681
+
682
+ ### Related records
683
+
684
+ Set `organise.showRelatedTab` to give the read view a tab for each other space with a `reference` or `listReference` column pointing at this record, beside Card View and named with its row count (e.g. `Orders (3)`). It is off by default: it shows other spaces' rows, and opening a record then runs one schema query plus one single-row count per referencing column. Spaces are found the same way the delete check finds them: through `devSettings.schemaEndpoint` (the workspace's datasheets, which needs the `space` prop) or `bareSettings.bareReferenceDel`. Only spaces with matching rows get a tab.
685
+
686
+ A tab shows that space as a read-only list Data Template (table, search, paging), filtered with `=` for a reference or `in` for a list reference. Picking a row opens it in the same tab with a Back button, so the record's own tabs stay on screen.
687
+
688
+ ```tsx
689
+ organise={{ viewId, handleNavigate, showRelatedTab: true }}
690
+ ```
691
+
692
+ ---
693
+
628
694
  ### With search and filter
629
695
 
630
696
  Use `organise.additionalQuery` to add filter controls above the list. Each entry maps to a filter UI control.
@@ -1091,6 +1157,11 @@ Which field types can be edited from the list table, and how. Types not listed u
1091
1157
  | `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` |
1092
1158
  | `savedViews` | `boolean` | Shows a row of view tags under the tab bar: save the current filters, flex filters, sort, rows per page and columns under a name, reapply it, update it or delete it. Views belong to the tab they were saved on and are kept in `localStorage` under `blixify-dtw-views-<collectionId>[-<persistKey>][-<currentUser.userEmail>]`. Sort is held by the template while it is on, as with `persistViewState`, and applying a view calls `onChangeSort`, `onChangePagination(0)` and `bareListHandleFilter` when those change. Each write re-reads storage first, so views saved in another browser tab are kept. With `onChangeHide` / `onChangeDrag`, a view's hides and column order stay on screen only: a toggle or reorder after applying one writes back the schema's columns plus that single change |
1093
1159
  | `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 |
1160
+ | `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` |
1161
+ | `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 |
1162
+ | `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` |
1163
+ | `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` |
1164
+ | `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` |
1094
1165
  | `groupBy` | `string` | Column ID to group list results by |
1095
1166
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
1096
1167
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
@@ -1105,6 +1176,7 @@ Which field types can be edited from the list table, and how. Types not listed u
1105
1176
  | `customPaginationCount` | `(pageDataCount: number) => void` | Called after each data fetch with the actual record count. Use this to override the count shown in the pagination bar — store the value in your own state and pass it back via `customPaginationSize` on `Table`, `Grid`, `List`, or `Pagination` if needed, or use it to display a custom count elsewhere in your UI |
1106
1177
  | `onAddColumns` | `(model: DataStructure[]) => void` | Called when a new column is added |
1107
1178
  | `handleNavigate` | `(type: string, selectedId?: string, viewId?: string) => void` | Called when the component needs to navigate (e.g. row click → read view) |
1179
+ | `showRelatedTab` | `boolean` | Adds a read view tab per other space whose rows reference the record. Default `false` |
1108
1180
  | `handleCustomDownloadFile` | `(url: string, fileName?: string) => void` | Overrides the default file download behaviour |
1109
1181
  | `renderReferenceLabelOptions` | `(spaceId?: string, includeReference?: boolean, type?: string) => Option[]` | Returns column options for reference label selectors |
1110
1182
  | `renderReferenceSpaceOptions` | `() => Option[]` | Returns space options for reference space selectors |
@@ -1233,12 +1305,13 @@ Injects custom React components and callbacks into the list, read, and update vi
1233
1305
  | ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------------- |
1234
1306
  | `name` | `string` | Tab label |
1235
1307
  | `type` | `"read" \| "list"` | `"list"` shows all data; `"read"` shows the selected record |
1236
- | `viewType` | `"grid" \| "list" \| "iframe" \| "calendar" \| "feeds" \| "kanban"` | View layout for this tab |
1308
+ | `viewType` | `"grid" \| "list" \| "iframe" \| "calendar" \| "feeds" \| "kanban" \| "chart"` | View layout for this tab |
1237
1309
  | `viewImage` | `string` | Column ID of an `image` field to display in grid/list view |
1238
1310
  | `src` | `string` | URL for iframe tabs |
1239
1311
  | `calendarSettings` | `CalendarSettings` | See Calendar Settings below |
1240
1312
  | `feedsSettings` | `FeedsSettings` | See Feeds Settings below |
1241
1313
  | `kanbanSettings` | `KanbanSettings` | See Kanban Settings below |
1314
+ | `chartSettings` | `Charts[]` | Charts drawn by a `"chart"` tab, see Chart view above |
1242
1315
 
1243
1316
  ### Additional Query
1244
1317
 
@@ -14,7 +14,7 @@ import {
14
14
  renderDemoRecords,
15
15
  TABS,
16
16
  } from "./savedViewsDemo.fixtures";
17
- import { buildSavedViewsKey } from "./viewStatePersistence";
17
+ import { buildSavedViewsKey, SharedSavedView } from "./viewStatePersistence";
18
18
 
19
19
  const meta = { title: "Data/Data Template" };
20
20
  export default meta;
@@ -140,3 +140,146 @@ export const SavedViews = () => (
140
140
  <SavedViewsDemo />
141
141
  </div>
142
142
  );
143
+
144
+ const SharedViewsDemo = () => {
145
+ const [viewId, setViewId] = useState("general");
146
+ const [isMockReady, setIsMockReady] = useState(false);
147
+ // INFO: what an app would store on the record the list belongs to
148
+ const [sharedViews, setSharedViews] = useState<SharedSavedView[]>([
149
+ {
150
+ id: "shared-due-last",
151
+ name: "Due last",
152
+ tabId: "general",
153
+ state: { sort: { sortId: "dueDate", type: "desc" } },
154
+ },
155
+ ]);
156
+ const [defaultSavedViewId, setDefaultSavedViewId] = useState<string | null>(
157
+ null,
158
+ );
159
+ const [openOn, setOpenOn] = useState("");
160
+ const [isEditor, setIsEditor] = useState(true);
161
+ // INFO: remounts the list, as reopening the page would
162
+ const [reloadKey, setReloadKey] = useState(0);
163
+ // INFO: hands the default over a moment after mount, as an app loading it
164
+ // from its record does
165
+ const [isDefaultLoading, setIsDefaultLoading] = useState(false);
166
+
167
+ const handleReloadWithLateDefault = () => {
168
+ setIsDefaultLoading(true);
169
+ setReloadKey((prev) => prev + 1);
170
+ setTimeout(() => setIsDefaultLoading(false), 1000);
171
+ };
172
+ const store = useMemo(() => renderDemoRecords(), []);
173
+
174
+ useEffect(() => {
175
+ const mock = handleInstallDemoMock(store);
176
+ setIsMockReady(true);
177
+ return () => {
178
+ mock.restore();
179
+ setIsMockReady(false);
180
+ };
181
+ }, [store]);
182
+
183
+ return (
184
+ <div className="w-full">
185
+ <div className="mb-4 flex flex-wrap items-center gap-3">
186
+ <ButtonGroup
187
+ selection={[
188
+ { id: "editor", name: "Editor" },
189
+ { id: "reader", name: "Reader" },
190
+ ]}
191
+ selectedId={isEditor ? "editor" : "reader"}
192
+ variant="tag"
193
+ size="small"
194
+ onClick={(id) => setIsEditor(id === "editor")}
195
+ />
196
+ <select
197
+ className="rounded-md border border-gray-300 px-2 py-1 text-sm"
198
+ value={openOn}
199
+ onChange={(event) => setOpenOn(event.target.value)}
200
+ >
201
+ <option value="">Open on: ★ default</option>
202
+ <option value="missing-view">Open on: a deleted view</option>
203
+ {sharedViews.map((eachView) => (
204
+ <option key={eachView.id} value={eachView.id}>
205
+ Open on: {eachView.name}
206
+ </option>
207
+ ))}
208
+ </select>
209
+ <button
210
+ type="button"
211
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
212
+ onClick={() => setReloadKey((prev) => prev + 1)}
213
+ >
214
+ Reload
215
+ </button>
216
+ <button
217
+ type="button"
218
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
219
+ onClick={handleReloadWithLateDefault}
220
+ >
221
+ Reload, default arrives late
222
+ </button>
223
+ </div>
224
+ {isMockReady && (
225
+ <DataTemplateImpl
226
+ key={`${openOn}-${isEditor}-${reloadKey}`}
227
+ title="Tasks"
228
+ description="40 offline records"
229
+ type="list"
230
+ collectionId={DEMO_COLLECTION}
231
+ model={DEMO_SCHEMA}
232
+ localURLEndpoint=""
233
+ devSettings={demoDevSettings}
234
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
235
+ limit={10}
236
+ organise={{
237
+ extendedTabs: TABS,
238
+ viewId,
239
+ savedViews: true,
240
+ sharedViews,
241
+ onChangeSharedViews: isEditor ? setSharedViews : undefined,
242
+ defaultSavedViewId: isDefaultLoading
243
+ ? undefined
244
+ : (defaultSavedViewId ?? undefined),
245
+ onChangeDefaultSavedViewId: isEditor
246
+ ? setDefaultSavedViewId
247
+ : undefined,
248
+ initialSavedViewId: openOn || undefined,
249
+ handleNavigate: (type: string, _id?: string, view?: string) => {
250
+ if (type === "list" && view) setViewId(view);
251
+ },
252
+ }}
253
+ bareSettings={{ bareNewDTW: true, bareFlexFilters: true }}
254
+ />
255
+ )}
256
+ <pre
257
+ data-testid="shared-views-store"
258
+ className="mt-4 max-h-48 overflow-auto rounded bg-gray-50 p-3 text-xs text-gray-600"
259
+ >
260
+ {JSON.stringify({ defaultSavedViewId, views: sharedViews }, null, 2)}
261
+ </pre>
262
+ </div>
263
+ );
264
+ };
265
+
266
+ export const SharedSavedViews = () => (
267
+ <div className="w-full">
268
+ <div className="font-semibold mb-1 text-gray-900">
269
+ Data Template — views stored by the app
270
+ </div>
271
+ <div className="text-sm mb-4 text-gray-600">
272
+ With sharedViews, views come from the app — here story state, shown below
273
+ — instead of this browser, so the whole team sees the same ones. As Editor
274
+ (onChangeSharedViews and onChangeDefaultSavedViewId given), “+ Save view”
275
+ hands the list to the app, and the ☆ on a view makes it the ★ default
276
+ everyone opens on; ★ again takes it off, leaving All. As Reader the views
277
+ are used, the default shows ★, and nothing can be changed. “Open on” sets
278
+ initialSavedViewId, how a page embedding a datasheet opens it on a chosen
279
+ view; a deleted view opens on All. “Reload” remounts the list, and
280
+ “default arrives late” hands the ★ over a second after mount, as an app
281
+ loading it does.
282
+ </div>
283
+ <SharedViewsDemo />
284
+ </div>
285
+ );
@@ -491,3 +491,536 @@ export const GridRawData = () => (
491
491
  <GridRawDataDemo />
492
492
  </div>
493
493
  );
494
+
495
+ // INFO: a chart tab asks the list endpoint for aggregates, so this mock
496
+ // answers those from the in-memory store — count/sum/avg over the records that
497
+ // match the request's "=" queries, one bucket per requested date range
498
+ const CHART_COLLECTION = "demo-chart-orders";
499
+
500
+ const CHART_SCHEMA: DataStructure[] = [
501
+ { id: "orderNo", name: "Order No", type: "string" },
502
+ {
503
+ id: "status",
504
+ name: "Status",
505
+ type: "list",
506
+ listSelection: ["pending", "paid", "shipped", "cancelled"],
507
+ listSelectionLabel: ["Pending", "Paid", "Shipped", "Cancelled"],
508
+ },
509
+ {
510
+ id: "channel",
511
+ name: "Channel",
512
+ type: "list",
513
+ listSelection: ["web", "store", "phone"],
514
+ listSelectionLabel: ["Web", "Store", "Phone"],
515
+ },
516
+ { id: "amount", name: "Amount", type: "number" },
517
+ { id: "items", name: "Items", type: "number" },
518
+ { id: "createdAt", name: "Created At", type: "date" },
519
+ ];
520
+
521
+ const renderChartRecords = () => {
522
+ const statuses = ["pending", "paid", "shipped", "cancelled"];
523
+ const channels = ["web", "store", "phone"];
524
+ return Array.from({ length: 40 }, (_, index) => ({
525
+ _id: `o${index + 1}`,
526
+ orderNo: `ORD-${1000 + index}`,
527
+ status: statuses[(index * 7) % statuses.length],
528
+ channel: channels[(index * 5) % channels.length],
529
+ amount: 20 + ((index * 37) % 180),
530
+ items: 1 + ((index * 3) % 6),
531
+ createdAt: new Date(Date.now() - (index % 7) * 24 * 60 * 60 * 1000),
532
+ }));
533
+ };
534
+
535
+ const handleParseRangeDate = (value: string) => {
536
+ const [date, time] = value.trim().split(" ");
537
+ const [day, month, year] = date.split("/").map(Number);
538
+ const [hour, minute, second] = (time ?? "00:00:00").split(":").map(Number);
539
+ return new Date(year, month - 1, day, hour, minute, second);
540
+ };
541
+
542
+ const handleInstallChartMock = (store: any[]) => {
543
+ const mock = new MockAdapter(axios, {
544
+ onNoMatch: "passthrough",
545
+ delayResponse: 120,
546
+ });
547
+ const listUrl = `${DEMO_ENDPOINT}/${CHART_COLLECTION}/list`;
548
+ const handleParseBody = (config: any) => {
549
+ try {
550
+ return JSON.parse(config?.data ?? "{}");
551
+ } catch (err) {
552
+ return {};
553
+ }
554
+ };
555
+ // INFO: "=" for chart groups, "search" for the search box — enough to show
556
+ // the table's filters reaching the charts
557
+ const handleMatch = (record: any, query: any[] = []) =>
558
+ query.every((eachQuery) => {
559
+ if (eachQuery.type === "=")
560
+ return record[eachQuery.queryId] === eachQuery.value;
561
+ if (eachQuery.type === "search")
562
+ return Object.values(record).some((eachValue) =>
563
+ String(eachValue)
564
+ .toLowerCase()
565
+ .includes(String(eachQuery.value).toLowerCase()),
566
+ );
567
+ return true;
568
+ });
569
+ const handleAggregate = (records: any[], aggregate: any) => {
570
+ if (aggregate.type === "count") return records.length;
571
+ const total = records.reduce(
572
+ (sum, eachRecord) => sum + Number(eachRecord[aggregate.queryId] ?? 0),
573
+ 0,
574
+ );
575
+ if (aggregate.type === "avg")
576
+ return records.length ? total / records.length : 0;
577
+ return total;
578
+ };
579
+
580
+ mock.onPost(listUrl).reply((config) => {
581
+ const body = handleParseBody(config);
582
+ const matched = store.filter((eachRecord) =>
583
+ handleMatch(eachRecord, body.query),
584
+ );
585
+ if (!body.aggregate) {
586
+ const cursor = typeof body.cursor === "number" ? body.cursor : 0;
587
+ const limit = typeof body.limit === "number" ? body.limit : 10;
588
+ return [
589
+ 200,
590
+ { data: matched.slice(cursor, cursor + limit), count: matched.length },
591
+ ];
592
+ }
593
+ const ranges: string[] = body.aggregate.range ?? [];
594
+ const data = ranges.map((eachRange) => {
595
+ const [start, end] = eachRange.split(" - ");
596
+ const inRange = end
597
+ ? matched.filter((eachRecord) => {
598
+ const date = new Date(eachRecord[body.aggregate.dateId]);
599
+ return (
600
+ date >= handleParseRangeDate(start) &&
601
+ date <= handleParseRangeDate(end)
602
+ );
603
+ })
604
+ : matched;
605
+ return {
606
+ _id: eachRange,
607
+ value: handleAggregate(inRange, body.aggregate),
608
+ };
609
+ });
610
+ return [200, { data }];
611
+ });
612
+
613
+ mock
614
+ .onPost(`${DEMO_ENDPOINT}/spaces/list`)
615
+ .reply(200, { data: [], count: 0 });
616
+
617
+ return mock;
618
+ };
619
+
620
+ const ChartViewDemo = () => {
621
+ const store = React.useMemo(() => renderChartRecords(), []);
622
+ React.useMemo(() => handleInstallChartMock(store), [store]);
623
+
624
+ const [typeParam, setTypeParam] = useState<"list" | "read" | "update">(
625
+ "list",
626
+ );
627
+ const [dataId, setDataId] = useState<string>("");
628
+ const [viewId, setViewId] = useState<string | undefined>(undefined);
629
+ const [tabs, setTabs] = useState<ExtendedTabs[]>([
630
+ {
631
+ name: "Overview",
632
+ type: "list",
633
+ viewType: "chart",
634
+ defaultTab: true,
635
+ chartSettings: [
636
+ {
637
+ title: "Orders",
638
+ type: "card",
639
+ cardEmoji: "📦",
640
+ gridSize: 1,
641
+ spaceId: CHART_COLLECTION,
642
+ query: [],
643
+ queryBaseType: "count",
644
+ },
645
+ {
646
+ title: "Revenue",
647
+ type: "card",
648
+ cardEmoji: "💰",
649
+ gridSize: 1,
650
+ spaceId: CHART_COLLECTION,
651
+ query: [],
652
+ queryBaseType: "sum",
653
+ queryAttributeId: ["amount"],
654
+ },
655
+ {
656
+ title: "Orders By Channel",
657
+ type: "pie",
658
+ gridSize: 2,
659
+ spaceId: CHART_COLLECTION,
660
+ query: ["web", "store", "phone"].map((eachValue) => ({
661
+ type: "=" as const,
662
+ queryId: "channel",
663
+ value: eachValue,
664
+ })),
665
+ seriesLabels: ["Web", "Store", "Phone"],
666
+ queryBaseType: "count",
667
+ },
668
+ {
669
+ title: "Orders Per Day",
670
+ type: "line",
671
+ gridSize: 2,
672
+ spaceId: CHART_COLLECTION,
673
+ query: [],
674
+ queryBaseType: "count",
675
+ queryBaseDateId: "createdAt",
676
+ },
677
+ {
678
+ title: "Revenue By Status",
679
+ type: "bar",
680
+ gridSize: 2,
681
+ spaceId: CHART_COLLECTION,
682
+ query: ["pending", "paid", "shipped", "cancelled"].map(
683
+ (eachValue) => ({
684
+ type: "=" as const,
685
+ queryId: "status",
686
+ value: eachValue,
687
+ }),
688
+ ),
689
+ seriesLabels: ["Pending", "Paid", "Shipped", "Cancelled"],
690
+ queryBaseType: "sum",
691
+ queryAttributeId: ["amount"],
692
+ },
693
+ {
694
+ title: "Orders By Status",
695
+ type: "table",
696
+ gridSize: 2,
697
+ spaceId: CHART_COLLECTION,
698
+ query: ["pending", "paid", "shipped", "cancelled"].map(
699
+ (eachValue) => ({
700
+ type: "=" as const,
701
+ queryId: "status",
702
+ value: eachValue,
703
+ }),
704
+ ),
705
+ seriesLabels: ["Pending", "Paid", "Shipped", "Cancelled"],
706
+ queryBaseType: "count",
707
+ },
708
+ ],
709
+ },
710
+ ]);
711
+
712
+ return (
713
+ <div className="w-full">
714
+ <DataTemplateImpl
715
+ title="Orders"
716
+ description="Charts over the same records the table lists"
717
+ type={typeParam}
718
+ id={dataId}
719
+ collectionId={CHART_COLLECTION}
720
+ model={CHART_SCHEMA}
721
+ localURLEndpoint=""
722
+ devSettings={demoDevSettings}
723
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
724
+ limit={10}
725
+ edit={{ allEditable: true }}
726
+ organise={{
727
+ extendedTabs: tabs,
728
+ viewId,
729
+ onChangeTabs: (value: ExtendedTabs[]) => setTabs(value),
730
+ handleNavigate: (type: string, id?: string, view?: string) => {
731
+ setTypeParam(type as any);
732
+ setDataId(id ?? "");
733
+ if (view) setViewId(view);
734
+ },
735
+ }}
736
+ />
737
+ </div>
738
+ );
739
+ };
740
+
741
+ export const ChartView = () => (
742
+ <div className="w-full">
743
+ <div className="font-semibold mb-1 text-gray-900">
744
+ Data Template — chart view
745
+ </div>
746
+ <div className="text-sm mb-4 text-gray-600">
747
+ A tab with <code>viewType: "chart"</code> draws its{" "}
748
+ <code>chartSettings</code> with the table's active filters applied. The +
749
+ and pencil on each chart open the builder; every change is sent through{" "}
750
+ <code>onChangeTabs</code>, the same as any other tab.
751
+ </div>
752
+ <ChartViewDemo />
753
+ </div>
754
+ );
755
+
756
+ // INFO: a Customers space that three other datasheets point at — Orders with a
757
+ // reference column, Support Tickets with a list reference, and Invoices whose
758
+ // rows reference other customers only. Notes holds a text column that names
759
+ // the space but is not a reference, which the Related tab must ignore.
760
+ const RELATED_WORKSPACE = "demo-workspace";
761
+ const RELATED_CUSTOMERS = "demo-customers";
762
+
763
+ const RELATED_CUSTOMER_SCHEMA: DataStructure[] = [
764
+ { id: "name", name: "Customer", type: "string" },
765
+ { id: "email", name: "Email", type: "email" },
766
+ {
767
+ id: "tier",
768
+ name: "Tier",
769
+ type: "list",
770
+ listSelection: ["standard", "gold"],
771
+ listSelectionLabel: ["Standard", "Gold"],
772
+ },
773
+ ];
774
+
775
+ const RELATED_SPACES: any[] = [
776
+ {
777
+ _id: "demo-orders",
778
+ name: "Orders",
779
+ type: "datasheet",
780
+ workspaceId: RELATED_WORKSPACE,
781
+ structure: [
782
+ { id: "orderNo", name: "Order No", type: "string" },
783
+ {
784
+ id: "status",
785
+ name: "Status",
786
+ type: "list",
787
+ listSelection: ["pending", "paid", "shipped"],
788
+ listSelectionLabel: ["Pending", "Paid", "Shipped"],
789
+ },
790
+ { id: "amount", name: "Amount", type: "number" },
791
+ { id: "createdAt", name: "Created At", type: "date" },
792
+ {
793
+ id: "customerId",
794
+ name: "Customer",
795
+ type: "reference",
796
+ referenceSpaceId: RELATED_CUSTOMERS,
797
+ referenceLabel: "name",
798
+ },
799
+ ],
800
+ },
801
+ {
802
+ _id: "demo-tickets",
803
+ name: "Support Tickets",
804
+ type: "datasheet",
805
+ workspaceId: RELATED_WORKSPACE,
806
+ structure: [
807
+ { id: "subject", name: "Subject", type: "string" },
808
+ {
809
+ id: "priority",
810
+ name: "Priority",
811
+ type: "list",
812
+ listSelection: ["low", "high"],
813
+ listSelectionLabel: ["Low", "High"],
814
+ },
815
+ { id: "openedAt", name: "Opened", type: "date" },
816
+ {
817
+ id: "customerIds",
818
+ name: "Customers",
819
+ type: "listReference",
820
+ referenceSpaceId: RELATED_CUSTOMERS,
821
+ referenceLabel: "name",
822
+ },
823
+ ],
824
+ },
825
+ {
826
+ _id: "demo-invoices",
827
+ name: "Invoices",
828
+ type: "datasheet",
829
+ workspaceId: RELATED_WORKSPACE,
830
+ structure: [
831
+ { id: "invoiceNo", name: "Invoice No", type: "string" },
832
+ { id: "total", name: "Total", type: "number" },
833
+ {
834
+ id: "customerId",
835
+ name: "Customer",
836
+ type: "reference",
837
+ referenceSpaceId: RELATED_CUSTOMERS,
838
+ referenceLabel: "name",
839
+ },
840
+ ],
841
+ },
842
+ {
843
+ _id: "demo-notes",
844
+ name: "Notes",
845
+ type: "datasheet",
846
+ workspaceId: RELATED_WORKSPACE,
847
+ structure: [
848
+ { id: "text", name: "Text", type: "string" },
849
+ {
850
+ id: "customerName",
851
+ name: "Customer Name",
852
+ type: "string",
853
+ referenceSpaceId: RELATED_CUSTOMERS,
854
+ },
855
+ ],
856
+ },
857
+ ];
858
+
859
+ const renderRelatedRecords = (): { [collection: string]: any[] } => {
860
+ const day = (offset: number) =>
861
+ new Date(Date.UTC(2026, 9, 12 - offset, 9)).toISOString();
862
+ return {
863
+ [RELATED_CUSTOMERS]: [
864
+ {
865
+ _id: "c1",
866
+ name: "Northwind Traders",
867
+ email: "orders@northwind.example",
868
+ tier: "gold",
869
+ },
870
+ {
871
+ _id: "c2",
872
+ name: "Contoso Retail",
873
+ email: "hello@contoso.example",
874
+ tier: "standard",
875
+ },
876
+ ],
877
+ "demo-orders": [
878
+ ["o1", "ORD-1001", "paid", 420, 1, "c1"],
879
+ ["o2", "ORD-1002", "shipped", 1280, 3, "c1"],
880
+ ["o3", "ORD-1003", "pending", 95, 5, "c2"],
881
+ ["o4", "ORD-1004", "pending", 310, 6, "c1"],
882
+ ].map(([_id, orderNo, status, amount, ago, customerId]) => ({
883
+ _id,
884
+ orderNo,
885
+ status,
886
+ amount,
887
+ createdAt: day(ago as number),
888
+ customerId,
889
+ })),
890
+ "demo-tickets": [
891
+ {
892
+ _id: "t1",
893
+ subject: "Late delivery",
894
+ priority: "high",
895
+ openedAt: day(2),
896
+ customerIds: ["c1"],
897
+ },
898
+ {
899
+ _id: "t2",
900
+ subject: "Invoice copy",
901
+ priority: "low",
902
+ openedAt: day(4),
903
+ customerIds: ["c1", "c2"],
904
+ },
905
+ ],
906
+ "demo-invoices": [
907
+ { _id: "i1", invoiceNo: "INV-77", total: 95, customerId: "c2" },
908
+ ],
909
+ "demo-notes": [{ _id: "n1", text: "Call back", customerName: "c1" }],
910
+ };
911
+ };
912
+
913
+ const handleInstallRelatedMock = (store: { [collection: string]: any[] }) => {
914
+ const mock = new MockAdapter(axios, {
915
+ onNoMatch: "passthrough",
916
+ delayResponse: 120,
917
+ });
918
+ const handleParseBody = (config: any) => {
919
+ try {
920
+ return JSON.parse(config?.data ?? "{}");
921
+ } catch (err) {
922
+ return {};
923
+ }
924
+ };
925
+ // INFO: "=" for a reference column, "in" for a list reference — the two
926
+ // queries the Related tab sends
927
+ const handleMatch = (record: any, query: any[] = []) =>
928
+ query.every((eachQuery) => {
929
+ const value = record[eachQuery.queryId];
930
+ if (eachQuery.type === "=") return value === eachQuery.value;
931
+ if (eachQuery.type === "in") {
932
+ const values = Array.isArray(value) ? value : [value];
933
+ return values.some((eachValue) => eachQuery.value.includes(eachValue));
934
+ }
935
+ return true;
936
+ });
937
+
938
+ mock.onPost(`${DEMO_ENDPOINT}/spaces/list`).reply(200, {
939
+ data: RELATED_SPACES,
940
+ count: RELATED_SPACES.length,
941
+ });
942
+
943
+ mock.onPost(new RegExp(`${DEMO_ENDPOINT}/([^/]+)/list$`)).reply((config) => {
944
+ const collection = config.url?.split("/").slice(-2)[0] ?? "";
945
+ const body = handleParseBody(config);
946
+ const rows = (store[collection] ?? []).filter((eachRecord) =>
947
+ handleMatch(eachRecord, body.query),
948
+ );
949
+ const limit = typeof body.limit === "number" ? body.limit : 10;
950
+ return [200, { data: rows.slice(0, limit), count: rows.length }];
951
+ });
952
+
953
+ mock.onPost(new RegExp(`${DEMO_ENDPOINT}/([^/]+)/get$`)).reply((config) => {
954
+ const collection = config.url?.split("/").slice(-2)[0] ?? "";
955
+ const body = handleParseBody(config);
956
+ return [
957
+ 200,
958
+ {
959
+ data:
960
+ (store[collection] ?? []).find(
961
+ (eachRecord) => eachRecord._id === body.id,
962
+ ) ?? null,
963
+ },
964
+ ];
965
+ });
966
+
967
+ return mock;
968
+ };
969
+
970
+ const RelatedRecordsDemo = () => {
971
+ const store = React.useMemo(() => renderRelatedRecords(), []);
972
+ React.useMemo(() => handleInstallRelatedMock(store), [store]);
973
+
974
+ const [typeParam, setTypeParam] = useState<"list" | "read" | "update">(
975
+ "read",
976
+ );
977
+ const [dataId, setDataId] = useState<string>("c1");
978
+ const [viewId, setViewId] = useState<string | undefined>(undefined);
979
+
980
+ return (
981
+ <div className="w-full">
982
+ <DataTemplateImpl
983
+ title="Customers"
984
+ type={typeParam}
985
+ id={dataId}
986
+ collectionId={RELATED_CUSTOMERS}
987
+ model={RELATED_CUSTOMER_SCHEMA}
988
+ localURLEndpoint=""
989
+ devSettings={demoDevSettings}
990
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
991
+ edit={{ deleteBtn: true }}
992
+ space={
993
+ {
994
+ _id: RELATED_CUSTOMERS,
995
+ name: "Customers",
996
+ workspaceId: RELATED_WORKSPACE,
997
+ } as any
998
+ }
999
+ organise={{
1000
+ viewId,
1001
+ showRelatedTab: true,
1002
+ handleNavigate: (type: string, id?: string, view?: string) => {
1003
+ setTypeParam(type as any);
1004
+ setDataId(id ?? "");
1005
+ setViewId(view);
1006
+ },
1007
+ }}
1008
+ />
1009
+ </div>
1010
+ );
1011
+ };
1012
+
1013
+ export const RelatedRecords = () => (
1014
+ <div className="w-full">
1015
+ <div className="font-semibold mb-1 text-gray-900">
1016
+ Data Template — related records
1017
+ </div>
1018
+ <div className="text-sm mb-4 text-gray-600">
1019
+ The read view adds a tab for each other space whose <code>reference</code>{" "}
1020
+ or <code>listReference</code> column points at this record. A tab lists
1021
+ those rows; a row opens in place with Back to the list, and the record's
1022
+ tabs stay on screen.
1023
+ </div>
1024
+ <RelatedRecordsDemo />
1025
+ </div>
1026
+ );
@@ -131,6 +131,7 @@ const navigation = [
131
131
  | `custom` | `any` | — | Custom link component used when `linkType` is `"react"`, `"gatsby"`, or `"next"` |
132
132
  | `compactMode` | `boolean` | `false` | Tighter rows, smaller icons and a smaller expand chevron, keeping every label |
133
133
  | `searchable` | `boolean` | `false` | Shows a filter box. Matches are listed flat, each with the path it sits under (e.g. `Sales › Reports`) |
134
+ | `searchAction` | `ReactElement` | — | A small control inside the right end of the filter box, such as a filter button opening the app's own pop-up. Shown only with `searchable` |
134
135
  | `persistKey` | `string` | — | localStorage key that remembers open groups and pages. A new key loads that key's stored state. |
135
136
  | `defaultExpandedIds` | `string[]` | — | Groups (by `groupId`) and pages (by `id`) open when nothing is stored for them |
136
137
 
@@ -1,4 +1,9 @@
1
- import { FolderIcon, HomeIcon, UsersIcon } from "@heroicons/react/24/outline";
1
+ import {
2
+ FolderIcon,
3
+ FunnelIcon,
4
+ HomeIcon,
5
+ UsersIcon,
6
+ } from "@heroicons/react/24/outline";
2
7
  import React, { useState } from "react";
3
8
  import { LeftNav as LeftNavImpl, Navigation } from ".";
4
9
  import { BarMove } from "../bars";
@@ -275,6 +280,17 @@ export const LeftNav = () => {
275
280
  const [workspaceSelectedId, setWorkspaceSelectedId] = useState("sales-q3");
276
281
  const [movableNav, setMovableNav] = useState(workspaceNavigation);
277
282
  const [lastMove, setLastMove] = useState("");
283
+ const [topLevelOnly, setTopLevelOnly] = useState(false);
284
+ // INFO: below lg the nav lives in a drawer opened from the header's menu
285
+ // button, so each story needs its own open state for that button to work on
286
+ // a phone
287
+ const [mobileStoryId, setMobileStoryId] = useState("");
288
+
289
+ const handleMobileMenu = (storyId: string) => ({
290
+ leftNavMobileMenu: mobileStoryId === storyId,
291
+ handleToggleLeftNavMobileMenu: () =>
292
+ setMobileStoryId(mobileStoryId === storyId ? "" : storyId),
293
+ });
278
294
 
279
295
  return (
280
296
  <>
@@ -304,12 +320,11 @@ export const LeftNav = () => {
304
320
  <LeftNavImpl
305
321
  selectedId={selectedId}
306
322
  navigation={groupedNav}
307
- leftNavMobileMenu={false}
323
+ {...handleMobileMenu("story-1")}
308
324
  draggable={true}
309
325
  handleBarsOnClick={(id) => setSelectedId(id)}
310
326
  handleChangeNavigation={(value) => setGroupedNav(value)}
311
327
  handleDropEnd={() => {}}
312
- handleToggleLeftNavMobileMenu={() => {}}
313
328
  renderDashboardLogo={() => renderLogo()}
314
329
  renderDashboardContent={renderContent}
315
330
  renderDashboardHeaderContent={renderHeader}
@@ -367,9 +382,8 @@ export const LeftNav = () => {
367
382
  groupId: "Site data",
368
383
  },
369
384
  ]}
370
- leftNavMobileMenu={false}
385
+ {...handleMobileMenu("story-2")}
371
386
  handleBarsOnClick={(id) => setGroupedSelectedId(id)}
372
- handleToggleLeftNavMobileMenu={() => {}}
373
387
  renderDashboardLogo={() => renderLogo()}
374
388
  renderDashboardContent={renderContent}
375
389
  renderDashboardHeaderContent={renderHeader}
@@ -385,11 +399,10 @@ export const LeftNav = () => {
385
399
  <LeftNavImpl
386
400
  selectedId={selectedId}
387
401
  navigation={navigation}
388
- leftNavMobileMenu={false}
402
+ {...handleMobileMenu("story-3")}
389
403
  transition={{ headerVisible: true, collapse }}
390
404
  handleBarsOnClick={(id) => setSelectedId(id)}
391
405
  handleToggleCollapse={() => setCollapse(!collapse)}
392
- handleToggleLeftNavMobileMenu={() => {}}
393
406
  renderDashboardLogo={() => renderLogo(collapse)}
394
407
  renderDashboardContent={renderContent}
395
408
  renderDashboardHeaderContent={renderHeader}
@@ -405,9 +418,8 @@ export const LeftNav = () => {
405
418
  <LeftNavImpl
406
419
  selectedId={selectedId}
407
420
  navigation={navigation}
408
- leftNavMobileMenu={false}
421
+ {...handleMobileMenu("story-4")}
409
422
  handleBarsOnClick={(id) => setSelectedId(id)}
410
- handleToggleLeftNavMobileMenu={() => {}}
411
423
  renderDashboardLogo={() => renderLogo()}
412
424
  renderDashboardContent={renderContent}
413
425
  renderDashboardHeaderContent={renderHeader}
@@ -451,9 +463,8 @@ export const LeftNav = () => {
451
463
  <LeftNavImpl
452
464
  selectedId={selectedId}
453
465
  navigation={manyNavigation}
454
- leftNavMobileMenu={false}
466
+ {...handleMobileMenu("story-5")}
455
467
  handleBarsOnClick={(id) => setSelectedId(id)}
456
- handleToggleLeftNavMobileMenu={() => {}}
457
468
  renderDashboardLogo={() => renderLogo()}
458
469
  renderDashboardContent={() => (
459
470
  <div className="p-6 text-gray-500 text-sm">Default density</div>
@@ -465,10 +476,9 @@ export const LeftNav = () => {
465
476
  <LeftNavImpl
466
477
  selectedId={selectedId}
467
478
  navigation={manyNavigation}
468
- leftNavMobileMenu={false}
479
+ {...handleMobileMenu("story-6")}
469
480
  compactMode={true}
470
481
  handleBarsOnClick={(id) => setSelectedId(id)}
471
- handleToggleLeftNavMobileMenu={() => {}}
472
482
  renderDashboardLogo={() => renderLogo()}
473
483
  renderDashboardContent={() => (
474
484
  <div className="p-6 text-gray-500 text-sm">compactMode</div>
@@ -487,12 +497,48 @@ export const LeftNav = () => {
487
497
  <LeftNavImpl
488
498
  selectedId={selectedId}
489
499
  navigation={manyNavigation}
490
- leftNavMobileMenu={false}
500
+ {...handleMobileMenu("story-7")}
491
501
  searchable={true}
492
502
  compactMode={true}
493
503
  searchPlaceholder="Filter pages"
494
504
  handleBarsOnClick={(id) => setSelectedId(id)}
495
- handleToggleLeftNavMobileMenu={() => {}}
505
+ renderDashboardLogo={() => renderLogo()}
506
+ renderDashboardContent={renderContent}
507
+ renderDashboardHeaderContent={renderHeader}
508
+ />
509
+ </div>
510
+ </StorySection>
511
+
512
+ <StorySection
513
+ title="Search Action"
514
+ description="searchAction puts a small control inside the right end of the filter box, so narrowing the nav costs no row of its own. Here a funnel button toggles a filter the app owns — top-level pages only — and shows a dot while it is on. LeftNav only draws the control; what it opens and filters is up to the app."
515
+ >
516
+ <div className="w-full h-96 relative overflow-hidden border rounded-lg">
517
+ <LeftNavImpl
518
+ selectedId={workspaceSelectedId}
519
+ navigation={
520
+ topLevelOnly
521
+ ? workspaceNavigation.filter((eachItem) => !eachItem.parentId)
522
+ : workspaceNavigation
523
+ }
524
+ {...handleMobileMenu("story-8")}
525
+ compactMode={true}
526
+ searchable={true}
527
+ searchPlaceholder="Filter spaces"
528
+ searchAction={
529
+ <button
530
+ type="button"
531
+ aria-label="Filter"
532
+ className="relative rounded p-0.5 text-gray-400 hover:text-gray-600"
533
+ onClick={() => setTopLevelOnly(!topLevelOnly)}
534
+ >
535
+ <FunnelIcon className="h-4 w-4" />
536
+ {topLevelOnly && (
537
+ <span className="absolute -right-0.5 -top-0.5 h-1.5 w-1.5 rounded-full bg-primary-600" />
538
+ )}
539
+ </button>
540
+ }
541
+ handleBarsOnClick={(id) => setWorkspaceSelectedId(id)}
496
542
  renderDashboardLogo={() => renderLogo()}
497
543
  renderDashboardContent={renderContent}
498
544
  renderDashboardHeaderContent={renderHeader}
@@ -508,12 +554,11 @@ export const LeftNav = () => {
508
554
  <LeftNavImpl
509
555
  selectedId={workspaceSelectedId}
510
556
  navigation={workspaceNavigation}
511
- leftNavMobileMenu={false}
557
+ {...handleMobileMenu("story-9")}
512
558
  compactMode={true}
513
559
  searchable={true}
514
560
  searchPlaceholder="Filter spaces"
515
561
  handleBarsOnClick={(id) => setWorkspaceSelectedId(id)}
516
- handleToggleLeftNavMobileMenu={() => {}}
517
562
  renderDashboardLogo={() => renderLogo()}
518
563
  renderDashboardContent={renderContent}
519
564
  renderDashboardHeaderContent={renderHeader}
@@ -529,7 +574,7 @@ export const LeftNav = () => {
529
574
  <LeftNavImpl
530
575
  selectedId={workspaceSelectedId}
531
576
  navigation={movableNav}
532
- leftNavMobileMenu={false}
577
+ {...handleMobileMenu("story-10")}
533
578
  compactMode={true}
534
579
  defaultExpandedIds={["ws-sales", "ws-marketing"]}
535
580
  handleBarsOnClick={(id) => setWorkspaceSelectedId(id)}
@@ -543,7 +588,6 @@ export const LeftNav = () => {
543
588
  !dragId.startsWith("ws-") &&
544
589
  (!targetId.startsWith("ws-") || position === "inside")
545
590
  }
546
- handleToggleLeftNavMobileMenu={() => {}}
547
591
  renderDashboardLogo={() => renderLogo()}
548
592
  renderDashboardContent={() => (
549
593
  <div className="p-6 text-sm text-gray-500">
@@ -565,10 +609,9 @@ export const LeftNav = () => {
565
609
  <LeftNavImpl
566
610
  selectedId={selectedId}
567
611
  navigation={twoGroupNavigation}
568
- leftNavMobileMenu={false}
612
+ {...handleMobileMenu("story-11")}
569
613
  persistKey="storybook-leftnav-expanded"
570
614
  handleBarsOnClick={(id) => setSelectedId(id)}
571
- handleToggleLeftNavMobileMenu={() => {}}
572
615
  renderDashboardLogo={() => renderLogo()}
573
616
  renderDashboardContent={renderContent}
574
617
  renderDashboardHeaderContent={renderHeader}
@@ -584,12 +627,11 @@ export const LeftNav = () => {
584
627
  <LeftNavImpl
585
628
  selectedId={selectedId}
586
629
  navigation={dragGroupNav}
587
- leftNavMobileMenu={false}
630
+ {...handleMobileMenu("story-12")}
588
631
  draggable={true}
589
632
  handleBarsOnClick={(id) => setSelectedId(id)}
590
633
  handleChangeNavigation={(value) => setDragGroupNav(value)}
591
634
  handleDropEnd={() => {}}
592
- handleToggleLeftNavMobileMenu={() => {}}
593
635
  renderDashboardLogo={() => renderLogo()}
594
636
  renderDashboardContent={renderContent}
595
637
  renderDashboardHeaderContent={renderHeader}
@@ -605,13 +647,12 @@ export const LeftNav = () => {
605
647
  <LeftNavImpl
606
648
  selectedId={selectedId}
607
649
  navigation={manyNavigation}
608
- leftNavMobileMenu={false}
650
+ {...handleMobileMenu("story-13")}
609
651
  transparent={true}
610
652
  mobileOpaque={true}
611
653
  compactMode={true}
612
654
  searchable={true}
613
655
  handleBarsOnClick={(id) => setSelectedId(id)}
614
- handleToggleLeftNavMobileMenu={() => {}}
615
656
  renderDashboardLogo={() => renderLogo()}
616
657
  renderDashboardContent={() => (
617
658
  <div className="p-6 text-gray-600 text-sm">
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.43",
3
+ "version": "0.1.46",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {