blixify-ui-web-mcp 0.1.42 → 0.1.44

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,60 @@ 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
+
628
682
  ### With search and filter
629
683
 
630
684
  Use `organise.additionalQuery` to add filter controls above the list. Each entry maps to a filter UI control.
@@ -1095,7 +1149,6 @@ Which field types can be edited from the list table, and how. Types not listed u
1095
1149
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
1096
1150
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
1097
1151
  | `unionCollections` | `any[]` | Additional collections to union-query alongside the main collection |
1098
- | `unionPagination` | `boolean` | Opt in to paging a union list: sends the page offset as `skip` and adds `_id` to the sort as a tie-break (default `false`). See *Union Collections* in the DataWrapper README |
1099
1152
  | `moreSettings` | `{ text: string; onClick: () => void }[]` | Extra items appended to the "more" dropdown menu |
1100
1153
  | `exportSettings` | `ExportSettings` | See Export Settings below |
1101
1154
  | `onChangeSort` | `(value: Sort) => void` | Called when the user changes the sort |
@@ -1234,12 +1287,13 @@ Injects custom React components and callbacks into the list, read, and update vi
1234
1287
  | ------------------ | ------------------------------------------------------------------- | ----------------------------------------------------------- |
1235
1288
  | `name` | `string` | Tab label |
1236
1289
  | `type` | `"read" \| "list"` | `"list"` shows all data; `"read"` shows the selected record |
1237
- | `viewType` | `"grid" \| "list" \| "iframe" \| "calendar" \| "feeds" \| "kanban"` | View layout for this tab |
1290
+ | `viewType` | `"grid" \| "list" \| "iframe" \| "calendar" \| "feeds" \| "kanban" \| "chart"` | View layout for this tab |
1238
1291
  | `viewImage` | `string` | Column ID of an `image` field to display in grid/list view |
1239
1292
  | `src` | `string` | URL for iframe tabs |
1240
1293
  | `calendarSettings` | `CalendarSettings` | See Calendar Settings below |
1241
1294
  | `feedsSettings` | `FeedsSettings` | See Feeds Settings below |
1242
1295
  | `kanbanSettings` | `KanbanSettings` | See Kanban Settings below |
1296
+ | `chartSettings` | `Charts[]` | Charts drawn by a `"chart"` tab, see Chart view above |
1243
1297
 
1244
1298
  ### Additional Query
1245
1299
 
@@ -491,3 +491,264 @@ 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
+ );
@@ -1017,28 +1017,23 @@ organise={{
1017
1017
 
1018
1018
  ---
1019
1019
 
1020
- ### Paging a union list — `unionPagination`
1020
+ ### Paging a union list
1021
1021
 
1022
- A union list does not page on its own. The DTW sends `skip: 0` for every union request, so page 2 (or the next scroll load) returns the first rows again. If the app fetches the whole union in one request and pages it itself, that is fine and nothing changes.
1022
+ A union list pages on the server like any other list. Each page request:
1023
1023
 
1024
- Set `organise.unionPagination` to let the server do the paging instead. It is **off by default**, so upgrading the library changes nothing until you opt in.
1024
+ - sends `skip = pageIndex × limit` (the first page is `0`);
1025
+ - sends the sort followed by `_id` in the same direction, e.g. `[{ sortId: "sortDate", type: "asc" }, { sortId: "_id", type: "asc" }]`. Every page is a separate request, and two rows with the same sort value have no fixed order between requests, so without the tie-break a row can appear on two pages or on none. With no sort set, the server default (`date` descending) is kept and `_id` is added after it.
1025
1026
 
1026
1027
  ```tsx
1027
1028
  organise={{
1028
1029
  unionCollections: [/* ... */],
1029
- unionPagination: true,
1030
1030
  paginationType: "scroll", // or "page"
1031
1031
  sort: { sortId: "sortDate", type: "asc" },
1032
1032
  }}
1033
1033
  limit={20}
1034
1034
  ```
1035
1035
 
1036
- With it on, each page request:
1037
-
1038
- - sends `skip = pageIndex × limit` (the first page is `0`);
1039
- - sends the sort followed by `_id` in the same direction, e.g. `[{ sortId: "sortDate", type: "asc" }, { sortId: "_id", type: "asc" }]`. Every page is a separate request, and two rows with the same sort value have no fixed order between requests, so without the tie-break a row can appear on two pages or on none. With no sort set, the server default (`date` descending) is kept and `_id` is added after it.
1040
-
1041
- It only applies with `unionCollections`. Calendar, map and timeline views fetch the full set and are not affected.
1036
+ Calendar, map, timeline and `groupId` views fetch the full set and are not paged.
1042
1037
 
1043
1038
  ---
1044
1039
 
@@ -0,0 +1,122 @@
1
+ import axios from "axios";
2
+ import MockAdapter from "axios-mock-adapter";
3
+ import React, { useEffect, useMemo, useState } from "react";
4
+ import { DataTemplateWrapper } from ".";
5
+ import {
6
+ DEFAULT_VIEWS,
7
+ DEMO_ENDPOINT,
8
+ DEMO_SCHEMA,
9
+ demoDevSettings,
10
+ renderDemoRecords,
11
+ } from "../dataTemplate/savedViewsDemo.fixtures";
12
+
13
+ const meta = { title: "Data/Data Wrapper/Scroll Filters" };
14
+ export default meta;
15
+
16
+ const COLLECTION = "demo-scroll-filter-tasks";
17
+
18
+ // INFO: an unfiltered read answers slower than a filtered one, so applying a
19
+ // view while the first load is in flight makes the old answer land last —
20
+ // the order that put rows from an earlier filter on screen
21
+ const UNFILTERED_DELAY = 1500;
22
+ const FILTERED_DELAY = 100;
23
+
24
+ const workspace = {
25
+ apiEndpointType: "mongoDB" as const,
26
+ apiEndpoint: DEMO_ENDPOINT,
27
+ apiEndpointToken: "",
28
+ apiUserToken: "",
29
+ assetEndpoint: "",
30
+ assetUploadEndpoint: "",
31
+ };
32
+
33
+ const handleInstallScrollMock = (store: any[]) => {
34
+ const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
35
+ mock.onPost(`${DEMO_ENDPOINT}/${COLLECTION}/list`).reply((config) => {
36
+ let body: any = {};
37
+ try {
38
+ body = JSON.parse(config?.data ?? "{}");
39
+ } catch (err) {
40
+ body = {};
41
+ }
42
+ const stageQuery = (body.query ?? []).find(
43
+ (eachQuery: any) => eachQuery?.queryId === "stage",
44
+ );
45
+ const rows = stageQuery
46
+ ? store.filter((each) => each.stage === stageQuery.value)
47
+ : store;
48
+ const cursor = typeof body.cursor === "number" ? body.cursor : 0;
49
+ const limit = typeof body.limit === "number" ? body.limit : rows.length;
50
+ return new Promise((resolve) =>
51
+ setTimeout(
52
+ () =>
53
+ resolve([
54
+ 200,
55
+ { data: rows.slice(cursor, cursor + limit), count: rows.length },
56
+ ]),
57
+ stageQuery ? FILTERED_DELAY : UNFILTERED_DELAY,
58
+ ),
59
+ );
60
+ });
61
+ mock
62
+ .onPost(`${DEMO_ENDPOINT}/spaces/list`)
63
+ .reply(200, { data: [], count: 0 });
64
+ return mock;
65
+ };
66
+
67
+ const ScrollFiltersDemo = () => {
68
+ const [isMockReady, setIsMockReady] = useState(false);
69
+ const store = useMemo(() => renderDemoRecords(), []);
70
+
71
+ // INFO: in an effect so it survives StrictMode's double mount
72
+ useEffect(() => {
73
+ const mock = handleInstallScrollMock(store);
74
+ setIsMockReady(true);
75
+ return () => {
76
+ mock.restore();
77
+ setIsMockReady(false);
78
+ };
79
+ }, [store]);
80
+
81
+ if (!isMockReady) return null;
82
+ return (
83
+ <DataTemplateWrapper
84
+ title="Tasks"
85
+ description="40 offline records"
86
+ type="list"
87
+ collectionId={COLLECTION}
88
+ schema={DEMO_SCHEMA}
89
+ workspace={workspace}
90
+ devSettings={{
91
+ schemaEndpoint: demoDevSettings.schemaEndpoint,
92
+ currentUser: demoDevSettings.currentUser,
93
+ }}
94
+ limit={10}
95
+ organise={{
96
+ breadcrumbType: "hidden",
97
+ paginationType: "scroll",
98
+ savedViews: true,
99
+ defaultSavedViews: DEFAULT_VIEWS,
100
+ }}
101
+ bareSettings={{
102
+ bareNewDTW: true,
103
+ bareFlexFilters: true,
104
+ }}
105
+ />
106
+ );
107
+ };
108
+
109
+ export const ScrollFilters = () => (
110
+ <div className="w-full">
111
+ <div className="font-semibold mb-1 text-gray-900">
112
+ DataTemplateWrapper — scroll list with flex filters
113
+ </div>
114
+ <div className="text-sm mb-4 text-gray-600">
115
+ A scroll-paginated list where an unfiltered read answers slower than a
116
+ filtered one. Apply “In progress” during the first load, or clear the
117
+ filter afterwards: the table must show only the rows of the current
118
+ filter, and the result count must match the rows on screen.
119
+ </div>
120
+ <ScrollFiltersDemo />
121
+ </div>
122
+ );
@@ -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.42",
3
+ "version": "0.1.44",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {