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.
- package/lib/components/data/dataGraph/README.md +5 -1
- package/lib/components/data/dataTemplate/README.md +56 -2
- package/lib/components/data/dataTemplate/dataTemplate.stories.tsx +261 -0
- package/lib/components/data/dataWrapper/README.md +5 -10
- package/lib/components/data/dataWrapper/dataWrapper.scrollFilters.stories.tsx +122 -0
- package/lib/components/navigation/leftNav/README.md +1 -0
- package/lib/components/navigation/leftNav/leftNav.stories.tsx +66 -25
- package/package.json +1 -1
|
@@ -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
|
|
1020
|
+
### Paging a union list
|
|
1021
1021
|
|
|
1022
|
-
A union list
|
|
1022
|
+
A union list pages on the server like any other list. Each page request:
|
|
1023
1023
|
|
|
1024
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
500
|
+
{...handleMobileMenu("story-7")}
|
|
491
501
|
searchable={true}
|
|
492
502
|
compactMode={true}
|
|
493
503
|
searchPlaceholder="Filter pages"
|
|
494
504
|
handleBarsOnClick={(id) => setSelectedId(id)}
|
|
495
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|