blixify-ui-web-mcp 0.1.25 → 0.1.27

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.
Files changed (27) hide show
  1. package/lib/components/data/dataTemplate/README.md +45 -22
  2. package/lib/components/data/dataTemplate/dataTemplate.savedViews.stories.tsx +142 -0
  3. package/lib/components/data/dataTemplate/dataTemplate.scrollRestore.stories.tsx +246 -0
  4. package/lib/components/data/dataTemplate/dataTemplate.stories.tsx +196 -38
  5. package/lib/components/data/dataWrapper/README.md +92 -49
  6. package/lib/components/data/dataWrapper/dataWrapper.edit.stories.tsx +33 -28
  7. package/lib/components/data/dataWrapper/dataWrapper.savedViews.stories.tsx +149 -0
  8. package/lib/components/data/dataWrapper/dataWrapper.utils.stories.tsx +852 -0
  9. package/lib/components/data/dataWrapper/dataWrapper.view.stories.tsx +107 -81
  10. package/lib/components/data/flexFilter/flexFilter.stories.tsx +223 -0
  11. package/lib/components/data/writeQuery/README.md +22 -0
  12. package/lib/components/display/modal/modal.stories.tsx +8 -5
  13. package/lib/components/display/newGrid/newGrid.stories.tsx +256 -0
  14. package/lib/components/display/newKanban/newKanban.stories.tsx +109 -51
  15. package/lib/components/input/datePicker/README.md +1 -0
  16. package/lib/components/input/select/README.md +1 -0
  17. package/lib/components/input/textInput/README.md +3 -1
  18. package/lib/components/input/textInput/textinput.stories.tsx +27 -0
  19. package/lib/components/navigation/leftNav/README.md +5 -0
  20. package/lib/components/navigation/leftNav/leftNav.stories.tsx +38 -0
  21. package/lib/mcp/tools/getBackendComponent.d.ts.map +1 -1
  22. package/lib/mcp/tools/getBackendComponent.js +4 -0
  23. package/lib/mcp/tools/getBackendComponent.js.map +1 -1
  24. package/lib/mcp/tools/getComponent.d.ts.map +1 -1
  25. package/lib/mcp/tools/getComponent.js +6 -1
  26. package/lib/mcp/tools/getComponent.js.map +1 -1
  27. package/package.json +1 -1
@@ -212,6 +212,26 @@ Hide and reorder are **per user** by default, kept in `persistViewState`. Wire
212
212
  the change is written to the model and handed to the consumer to persist for everyone — which
213
213
  is what a Space-backed DTW wants.
214
214
 
215
+ Persist the single change, not the list. `onChangeHide` passes the columns as shown plus the
216
+ edit that was made, and `onChangeDrag` passes the two column ids:
217
+
218
+ ```tsx
219
+ organise={{
220
+ onChangeHide: (_columns, change) => {
221
+ // INFO: apply the one edit to the stored columns — the on-screen list may carry a
222
+ // saved view, another tab's state or a copy older than the stored one
223
+ const next =
224
+ "showAll" in change
225
+ ? stored.map((each) => ({ ...each, hide: false }))
226
+ : stored.map((each) =>
227
+ each.id === change.columnId ? { ...each, hide: change.hide } : each,
228
+ );
229
+ handleSaveColumns(next);
230
+ },
231
+ onChangeDrag: (dragId, dropId) => handleSaveColumns(handleMove(stored, dragId, dropId)),
232
+ }}
233
+ ```
234
+
215
235
  ## Step-by-Step Setup
216
236
 
217
237
  **1. Install peer dependencies**
@@ -454,11 +474,12 @@ Set `organise.viewId="grid"` (or add an `extendedTabs` entry with `viewType="gri
454
474
  <p className="text-sm text-gray-500">{rowData.price}</p>
455
475
  </div>
456
476
  ),
457
- // Tags shown on the default grid card
458
- bareListGridTags: (rowData) => [rowData.category, rowData.status],
477
+ // Tags shown on the default grid card. The second argument is the fetched
478
+ // record, for values the display row has formatted away
479
+ bareListGridTags: (rowData, rawData) => [rawData?.category, rawData?.status],
459
480
  // Ribbon text on the default grid card
460
- bareListGridRibbonText: (rowData) =>
461
- rowData.status === "new" ? "New" : "",
481
+ bareListGridRibbonText: (rowData, rawData) =>
482
+ rawData?.status === "new" ? "New" : "",
462
483
  // Fallback when the image column is empty
463
484
  bareListGridFallbackImg: () => "/placeholder.png",
464
485
  }}
@@ -983,8 +1004,10 @@ Which field types can be edited from the list table, and how. Types not listed u
983
1004
  | `fixedPagination` | `boolean` | Fixes the pagination bar to the bottom of the viewport |
984
1005
  | `filterComponentNewRow` | `boolean` | Renders each filter component on its own row |
985
1006
  | `addColumnEnabled` | `boolean` | Shows an add-column button in the table header |
986
- | `persistViewState` | `"persistent" \| "session"` | Remembers the list's sort, filters, hidden columns, page and search per collection, so a Back navigation lands where the user left. `"persistent"` uses `localStorage`; `"session"` writes `sessionStorage` and reads it first, falling back to `localStorage`. List view only |
1007
+ | `persistViewState` | `"persistent" \| "session"` | Remembers the list's sort, filters, hidden columns, page, rows per page, search and reading position per collection, so a Back navigation lands where the user left. `"persistent"` uses `localStorage`; `"session"` writes `sessionStorage` and reads it first, falling back to `localStorage`. The reading position is restored on mount and on returning from a record, not on a tab switch; with several lists on one page, only the list a record was last opened from restores (tracked in `blixify-dtw-persist-last-drill`). Covers table, list, grid, kanban and feeds; not map. List view only |
987
1008
  | `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` |
1009
+ | `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 |
1010
+ | `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 |
988
1011
  | `groupBy` | `string` | Column ID to group list results by |
989
1012
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
990
1013
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
@@ -992,7 +1015,7 @@ Which field types can be edited from the list table, and how. Types not listed u
992
1015
  | `moreSettings` | `{ text: string; onClick: () => void }[]` | Extra items appended to the "more" dropdown menu |
993
1016
  | `exportSettings` | `ExportSettings` | See Export Settings below |
994
1017
  | `onChangeSort` | `(value: Sort) => void` | Called when the user changes the sort |
995
- | `onChangeHide` | `(value: DataStructure[]) => void` | Called when a column is hidden from the view settings panel. Providing it switches the panel to schema mode — the hide is written to the model for everyone instead of being this user's own |
1018
+ | `onChangeHide` | `(value: DataStructure[], change: HideChange) => void` | Called when a column is hidden or shown from the view settings panel. Providing it switches the panel to schema mode — the hide is written to the model for everyone instead of being this user's own. `change` is `{ columnId, hide }` for one column or `{ showAll: true }`; persist that rather than `value` |
996
1019
  | `onChangeDrag` | `(dragId: string, dropId: string, newHeader: TableHeader[]) => void` | Called after a column is reordered in the view settings panel, so the consumer can persist the order |
997
1020
  | `onChangeTabs` | `(value: ExtendedTabs[]) => void` | Called when tabs are added/removed/reordered |
998
1021
  | `onChangePagination` | `(page: number) => void` | Called when the page changes |
@@ -1068,15 +1091,15 @@ Injects custom React components and callbacks into the list, read, and update vi
1068
1091
  | `bareReadDescription` | `(key: string, value: any) => any` | Custom renderer for read view field values |
1069
1092
  | `bareReadHandleData` | `(data: any, rawData?: any) => void` | Called when read view data is loaded. `data` is display data (labels + elements); `rawData` is the untouched DB doc |
1070
1093
  | `bareReadListTabs` | `boolean` | Shows list-style tabs in read view |
1071
- | `bareListComponent` | `(data, prettierData, loading, pageIndex) => any` | Fully replaces the list UI with a custom component |
1072
- | `bareListActionBtns` | `(data: any) => ReactElement[]` | Extra icon buttons rendered on each list row |
1094
+ | `bareListComponent` | `(data, prettierData, loading, pageIndex) => any` | Fully replaces the list UI. `data` is the fetched records (`_id` exposed as `id`), `prettierData` the display rows |
1095
+ | `bareListActionBtns` | `(data: any) => ReactElement[]` | Extra icon buttons on each list row. `data` is the raw fetched record |
1073
1096
  | `bareListMultipleSelectAddtionalActions` | `{ text: string; className?: string; onClick: (ids, selectAll, query) => void }[]` | Extra bulk-action buttons shown when rows are selected |
1074
- | `bareListGridComponent` | `(data: any) => any` | Custom card renderer for grid view |
1097
+ | `bareListGridComponent` | `(data: any, rawData?: any) => any` | Custom card renderer for grid/kanban view. `data` is display data; `rawData` is the fetched record for that card (`_id` exposed as `id`) |
1075
1098
  | `bareListGridColClassName` | `string` | Tailwind class for grid column width |
1076
- | `bareListGridTags` | `(data: any) => string[]` | Returns tag strings shown on grid cards |
1077
- | `bareListGridFallbackImg` | `(data: any) => string \| ReactElement` | Fallback image/element for grid cards without an image |
1078
- | `bareListGridRibbonText` | `(data: any) => string` | Text shown as a ribbon on grid cards |
1079
- | `bareListHandleData` | `(data, prettierData) => void` | Called when list data is loaded |
1099
+ | `bareListGridTags` | `(data: any, rawData?: any) => string[]` | Tag strings shown on grid cards. `rawData` is the fetched record for that card (`_id` exposed as `id`) |
1100
+ | `bareListGridFallbackImg` | `(data: any, rawData?: any) => string \| ReactElement` | Fallback image for grid cards. `rawData` is the fetched record for that card (`_id` exposed as `id`) |
1101
+ | `bareListGridRibbonText` | `(data: any, rawData?: any) => string` | Ribbon text on grid cards. `rawData` is the fetched record for that card (`_id` exposed as `id`) |
1102
+ | `bareListHandleData` | `(data, prettierData) => void` | Called when list data is loaded. `data` is the fetched records (`_id` exposed as `id`), `prettierData` the display rows |
1080
1103
  | `bareListHandleSelection` | `(selectAll, selectedIds, query?) => void` | Called when row selection changes |
1081
1104
  | `bareListHandleFilter` | `(search, select, date, reference) => void` | Called when any filter changes |
1082
1105
  | `bareListFilterSelected` | `(filterSearchParams, filterSelection, filterReference, filterReferenceDate) => void` | Called with the current filter state whenever it changes |
@@ -1084,13 +1107,13 @@ Injects custom React components and callbacks into the list, read, and update vi
1084
1107
  | `bareListFilterQuery` | `Query[]` | Additional queries always applied to list fetches |
1085
1108
  | `bareListFilterAddComponent` | `ReactElement[]` | Extra filter components rendered in the filter bar |
1086
1109
  | `bareListPipeline` | `PipeLine[]` | Additional pipeline stages appended to list queries |
1087
- | `bareListObjectHandleData` | `(type: "read" \| "update" \| "new", data: any) => void` | Called when a list-object row is opened |
1088
- | `bareUpdatePrefill` | `(data: any) => { key: string; value: any }[]` | Returns prefill values for the update form based on existing data |
1089
- | `bareUpdateComponent` | `(data: any) => any` | Custom component rendered inside the update form |
1110
+ | `bareListObjectHandleData` | `(type: "read" \| "update" \| "new", data: any, rawData?: any) => void` | Called when a list-object row is opened. `rawData` is that row as stored (undefined for a new row) |
1111
+ | `bareUpdatePrefill` | `(data: any, rawData?: any) => { key: string; value: any }[]` | Returns prefill values for the update form. `rawData` is the stored doc as loaded (undefined for a new record) |
1112
+ | `bareUpdateComponent` | `(data: any, rawData?: any) => any` | Custom component rendered inside the update form. `rawData` is the stored doc as loaded (undefined for a new record) |
1090
1113
  | `bareUpdateComponentAbove` | `boolean` | Renders `bareUpdateComponent` above the default form fields |
1091
1114
  | `bareSaveOnTabChange` | `boolean` | Auto-save: every Next / Previous on a tabbed update form writes a draft first, silently (no toast). Pair with `edit.enableUpdateDraft` |
1092
1115
  | `bareUpsertPartialUpdate` | `boolean` | Upsert by `props.id` and send only the fields that changed since load; a save with no changes skips the write. Makes auto-save safe across devices |
1093
- | `bareUpdateActionComponent` | `(data: any, formRef?: any) => any` | Custom action buttons rendered in the update form footer |
1116
+ | `bareUpdateActionComponent` | `(data: any, formRef?: any, rawData?: any) => any` | Custom action buttons in the form footer. `rawData` is the stored doc as loaded (undefined for a new record) |
1094
1117
  | `bareFlexFilters` | `boolean` | Funnel icon opens the Filters builder (column · condition · value, values from the schema) — replaces hand-rolled page filters; new table only |
1095
1118
  | `bareOnFlexFiltersChange` | `(flexFilters: FlexFilterData[]) => void` | Fires when the applied (debounced) flex filters change — from user panel edits or programmatic `handleApplyFlexFilters` / `handleClearFlexFilters`. Lets a parent observe live filter state. Needs `bareFlexFilters` |
1096
1119
  | `bareListFilterAsPopup` | `boolean` | Shows the list's filter inputs in a popup instead of the header row |
@@ -1100,15 +1123,15 @@ Injects custom React components and callbacks into the list, read, and update vi
1100
1123
  | `bareMobileTooltip` | `MobileViewTooltip` | Passed to NewTable as `mobileTooltip` when `bareNewDTW` and `bareListMobileView` are on: a Tag, trailing text and summary line on the collapsed row, plus a hover card. See the NewTable README → Mobile tooltip |
1101
1124
  | `bareListMobileTooltip` | `NewListMobileTooltip` | Passed to NewList when `bareNewDTW` is on: `imageKey`, `summaryKeys`, `tagKeys` and `tagColors` for the hover card on list items |
1102
1125
  | `bareListMaxSelectedCount` | `number` | Caps how many rows the checkbox column may select at once |
1103
- | `bareUpdateHandleData` | `(data: any, changedAttributeIds?: string[]) => void` | Called on every form field change with the current data and changed field IDs |
1104
- | `bareUpdateHandlePreComplete` | `(data: any) => Promise<boolean>` | Called before save — return `false` to cancel |
1105
- | `bareUpdateHandleComplete` | `(data: any) => Promise<any>` | Called after save completes |
1106
- | `bareUpdateHandlePostComplete` | `(data: any) => Promise<any>` | Called after post-save operations complete |
1126
+ | `bareUpdateHandleData` | `(data: any, changedAttributeIds?: string[], rawData?: any) => void` | Called on every form field change with the current data and changed field IDs. `rawData` is the stored doc as loaded (undefined for a new record) |
1127
+ | `bareUpdateHandlePreComplete` | `(data: any, rawData?: any) => Promise<boolean>` | Called before save — return `false` to cancel. `rawData` is the stored doc as loaded (undefined for a new record) |
1128
+ | `bareUpdateHandleComplete` | `(data: any, rawData?: any) => Promise<any>` | Called after save completes. `rawData` is the stored doc as loaded (undefined for a new record) |
1129
+ | `bareUpdateHandlePostComplete` | `(data: any, rawData?: any) => Promise<any>` | Called after post-save operations complete. `rawData` is the stored doc as loaded (undefined for a new record); undefined for offline uploads |
1107
1130
  | `bareUpdateColumnType` | `"one" \| "two" \| "four"` | Form column layout: `"one"` = 2-col grid, `"two"` = 4-col grid (default), `"four"` = 4-col grid |
1108
1131
  | `bareUpdateTabComponent` | `(tabIds: string[]) => { tabId: string; icon?: ReactElement; skip?: boolean }[]` | Custom icons/skip flags for form tabs |
1109
1132
  | `bareUpdateActionBottom` | `boolean` | Plain right-aligned action row instead of the floating pill — for short forms inside a `Modal` |
1110
1133
  | `bareUpdateTabId` | `(tabId: string) => void` | Called when the active form tab changes |
1111
- | `bareUpdateIndividualComponent` | `(columnId, ref, selectedData, setSelectedData) => ReactElement \| null` | Replaces a specific form field with a custom component |
1134
+ | `bareUpdateIndividualComponent` | `(columnId, ref, selectedData, setSelectedData, rawData?) => ReactElement \| null` | Replaces a specific form field with a custom component. `rawData` is the stored doc as loaded (undefined for a new record) |
1112
1135
  | `bareUpdateMobileView` | `boolean` | Renders the update form in mobile-optimised layout |
1113
1136
  | `bareOffline` | `boolean` | Enables offline data caching via IndexedDB |
1114
1137
  | `bareOfflineNetworkListener` | `boolean` | With `bareOffline`: follow `navigator.onLine` — saves go to the server while online and to IndexedDB only when the network is down. Without it every save goes to IndexedDB until `handleUploadOfflineData()` |
@@ -0,0 +1,142 @@
1
+ import { Menu, Transition } from "@headlessui/react";
2
+ import axios from "axios";
3
+ import Papa from "papaparse";
4
+ import React, { useEffect, useMemo, useState } from "react";
5
+ import { ButtonGroup } from "../../action/buttonGroup";
6
+ import { DataTemplate as DataTemplateImpl } from ".";
7
+ import {
8
+ DEFAULT_VIEWS,
9
+ DEMO_COLLECTION,
10
+ demoDevSettings,
11
+ DEMO_SCHEMA,
12
+ handleInstallDemoMock,
13
+ OWNERS,
14
+ renderDemoRecords,
15
+ TABS,
16
+ } from "./savedViewsDemo.fixtures";
17
+ import { buildSavedViewsKey } from "./viewStatePersistence";
18
+
19
+ const meta = { title: "Data/Data Template" };
20
+ export default meta;
21
+
22
+ // INFO: bareFlexFilters replaces the toolbar filters, so the two never show together
23
+ const FILTER_MODES = [
24
+ { id: "flex", name: "Flex filters" },
25
+ { id: "toolbar", name: "Toolbar filters" },
26
+ ];
27
+
28
+ const SavedViewsDemo = () => {
29
+ const [viewId, setViewId] = useState("general");
30
+ const [isMockReady, setIsMockReady] = useState(false);
31
+ // INFO: remounts the template after the stored views are cleared
32
+ const [resetKey, setResetKey] = useState(0);
33
+ const [filterMode, setFilterMode] = useState<"flex" | "toolbar">("flex");
34
+
35
+ const store = useMemo(() => renderDemoRecords(), []);
36
+ const storageKey = buildSavedViewsKey(
37
+ DEMO_COLLECTION,
38
+ undefined,
39
+ demoDevSettings.currentUser.userEmail,
40
+ );
41
+
42
+ // INFO: in an effect so it survives StrictMode's double mount
43
+ useEffect(() => {
44
+ const mock = handleInstallDemoMock(store);
45
+ setIsMockReady(true);
46
+ return () => {
47
+ mock.restore();
48
+ setIsMockReady(false);
49
+ };
50
+ }, [store]);
51
+
52
+ const handleClearStoredViews = () => {
53
+ try {
54
+ window.localStorage.removeItem(storageKey);
55
+ } catch (err) {
56
+ // INFO: storage unavailable — nothing to clear
57
+ }
58
+ setResetKey((prev) => prev + 1);
59
+ };
60
+
61
+ return (
62
+ <div className="w-full">
63
+ <div className="mb-4 flex flex-wrap items-center gap-3">
64
+ <ButtonGroup
65
+ selection={FILTER_MODES}
66
+ selectedId={filterMode}
67
+ variant="tag"
68
+ size="small"
69
+ onClick={(id) => setFilterMode(id as "flex" | "toolbar")}
70
+ />
71
+ <button
72
+ type="button"
73
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
74
+ onClick={handleClearStoredViews}
75
+ >
76
+ Clear my saved views
77
+ </button>
78
+ </div>
79
+ {isMockReady && (
80
+ <DataTemplateImpl
81
+ key={`${resetKey}-${filterMode}`}
82
+ title="Tasks"
83
+ description="40 offline records"
84
+ type="list"
85
+ collectionId={DEMO_COLLECTION}
86
+ model={DEMO_SCHEMA}
87
+ localURLEndpoint=""
88
+ devSettings={demoDevSettings}
89
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
90
+ limit={10}
91
+ organise={{
92
+ extendedTabs: TABS,
93
+ viewId,
94
+ savedViews: true,
95
+ // INFO: toolbar filters on their own full-width row, instead of
96
+ // sharing the title row with the action buttons
97
+ filterComponentNewRow: true,
98
+ // INFO: the toolbar filters outside the flex filter popup — normal
99
+ // size, and captured by saved views like any other filter
100
+ additionalQuery: [
101
+ {
102
+ type: "list",
103
+ value: "owner",
104
+ options: OWNERS,
105
+ filterLabel: "Owner",
106
+ },
107
+ { type: "range", value: "dueDate" },
108
+ ],
109
+ defaultSavedViews: DEFAULT_VIEWS,
110
+ handleNavigate: (type: string, _id?: string, view?: string) => {
111
+ if (type === "list" && view) setViewId(view);
112
+ },
113
+ }}
114
+ bareSettings={{
115
+ bareNewDTW: true,
116
+ bareFlexFilters: filterMode === "flex",
117
+ }}
118
+ />
119
+ )}
120
+ </div>
121
+ );
122
+ };
123
+
124
+ export const SavedViews = () => (
125
+ <div className="w-full">
126
+ <div className="font-semibold mb-1 text-gray-900">
127
+ Data Template — saved views
128
+ </div>
129
+ <div className="text-sm mb-4 text-gray-600">
130
+ Offline, with 40 tasks. “Flex filters” uses the compact filter popup;
131
+ “Toolbar filters” shows the normal Owner and Due filters above the list
132
+ instead. Set a filter, a sort or hide a column, then “+ Save view” and
133
+ name it. Click a view to apply it, “All” to go back, and × to delete one.
134
+ Change something while a view is active and it shows “•” with an Update
135
+ action. Views belong to the tab they were saved on — the Board tab keeps
136
+ its own list. “In progress” and “Top priority” are provided by the app and
137
+ cannot be deleted. Views are kept in this browser, so they survive a
138
+ reload.
139
+ </div>
140
+ <SavedViewsDemo />
141
+ </div>
142
+ );
@@ -0,0 +1,246 @@
1
+ import { Menu, Transition } from "@headlessui/react";
2
+ import axios from "axios";
3
+ import MockAdapter from "axios-mock-adapter";
4
+ import Papa from "papaparse";
5
+ import React, { useEffect, useMemo, useState } from "react";
6
+ import { ButtonGroup } from "../../action/buttonGroup";
7
+ import { DataTemplate as DataTemplateImpl } from ".";
8
+ import { DataStructure } from "./model";
9
+ import { ExtendedTabs } from "./spaceModel";
10
+
11
+ const meta = { title: "Data/Data Template" };
12
+ export default meta;
13
+
14
+ // INFO: its own endpoint, so the mock never answers for another story
15
+ const DEMO_ENDPOINT = "https://dtw-scroll-demo.local/api";
16
+ const DEMO_COLLECTION = "demo-scroll-records";
17
+ const RECORD_COUNT = 60;
18
+ const STAGES = ["New", "In Progress", "Done"];
19
+ const OWNERS = ["Alice", "Ben", "Chitra", "Dan"];
20
+
21
+ const DEMO_SCHEMA: DataStructure[] = [
22
+ { id: "name", name: "Record", type: "string" },
23
+ { id: "stage", name: "Stage", type: "list", listSelection: STAGES },
24
+ { id: "owner", name: "Owner", type: "string" },
25
+ { id: "summary", name: "Summary", type: "string" },
26
+ ];
27
+
28
+ const TABS: ExtendedTabs[] = [
29
+ { name: "List", type: "list", viewType: "list" },
30
+ { name: "Grid", type: "list", viewType: "grid" },
31
+ {
32
+ name: "Kanban",
33
+ type: "list",
34
+ viewType: "kanban",
35
+ kanbanSettings: { title: "stage" },
36
+ },
37
+ {
38
+ name: "Feeds",
39
+ type: "list",
40
+ viewType: "feeds",
41
+ feedsSettings: { title: "name", content: "stage", desc: "summary" },
42
+ },
43
+ ] as ExtendedTabs[];
44
+
45
+ const DTW_TOGGLE = [
46
+ { id: "new", name: "New DTW" },
47
+ { id: "legacy", name: "Legacy" },
48
+ ];
49
+ const LAYOUT_TOGGLE = [
50
+ { id: "desktop", name: "Desktop" },
51
+ { id: "mobile", name: "Mobile" },
52
+ ];
53
+
54
+ const demoDevSettings: any = {
55
+ server: "mongoServer",
56
+ dbEndpoint: DEMO_ENDPOINT,
57
+ dbAPIToken: "",
58
+ dbUserToken: "",
59
+ schemaEndpoint: DEMO_ENDPOINT,
60
+ schemaEndpointToken: "",
61
+ imageEndpoint: "https://placehold.co/600x400/e2e8f0/475569.png?text=",
62
+ uploadEndpoint: "",
63
+ };
64
+
65
+ const renderDemoRecords = () =>
66
+ Array.from({ length: RECORD_COUNT }, (_, index) => {
67
+ const number = String(index + 1).padStart(2, "0");
68
+ return {
69
+ _id: `rec-${number}`,
70
+ name: `Record ${number}`,
71
+ stage: STAGES[index % STAGES.length],
72
+ owner: OWNERS[index % OWNERS.length],
73
+ summary: `Summary for record ${number}`,
74
+ };
75
+ });
76
+
77
+ // INFO: Kanban queries per column — unfiltered, every card would appear in every column
78
+ const handleApplyQuery = (rows: any[], query: any[] = []) =>
79
+ query.reduce((filtered: any[], eachQuery: any) => {
80
+ if (!eachQuery?.queryId) return filtered;
81
+ if (eachQuery.type === "=" || eachQuery.type === "==")
82
+ return filtered.filter(
83
+ (eachRow) => eachRow[eachQuery.queryId] === eachQuery.value,
84
+ );
85
+ if (eachQuery.type === "in" && Array.isArray(eachQuery.value))
86
+ return filtered.filter((eachRow) =>
87
+ eachQuery.value.includes(eachRow[eachQuery.queryId]),
88
+ );
89
+ return filtered;
90
+ }, rows);
91
+
92
+ const handleInstallDemoMock = (store: any[]) => {
93
+ const mock = new MockAdapter(axios, {
94
+ onNoMatch: "passthrough",
95
+ delayResponse: 150,
96
+ });
97
+ const renderUrl = (type: string) =>
98
+ `${DEMO_ENDPOINT}/${DEMO_COLLECTION}/${type}`;
99
+ const handleParseBody = (config: any) => {
100
+ try {
101
+ return JSON.parse(config?.data ?? "{}");
102
+ } catch (err) {
103
+ return {};
104
+ }
105
+ };
106
+
107
+ mock.onPost(renderUrl("list")).reply((config) => {
108
+ const body = handleParseBody(config);
109
+ const rows = handleApplyQuery(store, body.query);
110
+ const cursor = typeof body.cursor === "number" ? body.cursor : 0;
111
+ const limit = typeof body.limit === "number" ? body.limit : RECORD_COUNT;
112
+ return [
113
+ 200,
114
+ { data: rows.slice(cursor, cursor + limit), count: rows.length },
115
+ ];
116
+ });
117
+
118
+ mock.onPost(renderUrl("get")).reply((config) => {
119
+ const body = handleParseBody(config);
120
+ return [200, { data: store.find((each) => each._id === body.id) ?? null }];
121
+ });
122
+
123
+ // INFO: the tab bar waits on this lookup
124
+ mock
125
+ .onPost(`${DEMO_ENDPOINT}/spaces/list`)
126
+ .reply(200, { data: [], count: 0 });
127
+
128
+ return mock;
129
+ };
130
+
131
+ const ScrollRestoreDemo = () => {
132
+ const [typeParam, setTypeParam] = useState<"list" | "read" | "update">(
133
+ "list",
134
+ );
135
+ const [dataId, setDataId] = useState<string>("");
136
+ // INFO: kept apart from viewId — opening a record navigates with "general"
137
+ const [listViewId, setListViewId] = useState<string>("general");
138
+ const [dtwMode, setDtwMode] = useState<"new" | "legacy">("new");
139
+ const [layout, setLayout] = useState<"desktop" | "mobile">("desktop");
140
+ const [isMockReady, setIsMockReady] = useState(false);
141
+
142
+ const store = useMemo(() => renderDemoRecords(), []);
143
+ const isMobile = layout === "mobile";
144
+ const isNewDTW = dtwMode === "new";
145
+
146
+ // INFO: in an effect so it survives StrictMode's double mount
147
+ useEffect(() => {
148
+ const mock = handleInstallDemoMock(store);
149
+ setIsMockReady(true);
150
+ return () => {
151
+ mock.restore();
152
+ setIsMockReady(false);
153
+ };
154
+ }, [store]);
155
+
156
+ const handleNavigate = (type: string, id?: string, view?: string) => {
157
+ if (type === "list" && view) setListViewId(view);
158
+ setTypeParam(type as "list" | "read" | "update");
159
+ setDataId(id ?? "");
160
+ };
161
+
162
+ const handleBackToList = () => handleNavigate("list", "", listViewId);
163
+
164
+ const renderToggles = () => (
165
+ <div className="mb-4 flex flex-wrap gap-3">
166
+ <ButtonGroup
167
+ selection={DTW_TOGGLE}
168
+ selectedId={dtwMode}
169
+ variant="tag"
170
+ size="small"
171
+ onClick={(id) => setDtwMode(id as "new" | "legacy")}
172
+ />
173
+ <ButtonGroup
174
+ selection={LAYOUT_TOGGLE}
175
+ selectedId={layout}
176
+ variant="tag"
177
+ size="small"
178
+ onClick={(id) => setLayout(id as "desktop" | "mobile")}
179
+ />
180
+ {typeParam !== "list" && (
181
+ <button
182
+ id="scroll-restore-back"
183
+ type="button"
184
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
185
+ onClick={handleBackToList}
186
+ >
187
+ ← Back to list
188
+ </button>
189
+ )}
190
+ </div>
191
+ );
192
+
193
+ return (
194
+ <div className="w-full">
195
+ {renderToggles()}
196
+ <div className={isMobile ? "mx-auto max-w-sm" : "w-full"}>
197
+ {/* INFO: keyed on the mode and layout so switching either one starts a
198
+ fresh DataTemplate instead of reusing state from the other set of
199
+ components */}
200
+ {isMockReady && (
201
+ <DataTemplateImpl
202
+ key={`${dtwMode}-${layout}`}
203
+ title="Scroll Restore"
204
+ description={`${RECORD_COUNT} offline records`}
205
+ type={typeParam}
206
+ id={dataId}
207
+ collectionId={DEMO_COLLECTION}
208
+ model={DEMO_SCHEMA}
209
+ localURLEndpoint=""
210
+ devSettings={demoDevSettings}
211
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
212
+ limit={RECORD_COUNT}
213
+ organise={{
214
+ extendedTabs: TABS,
215
+ viewId: typeParam === "list" ? listViewId : "general",
216
+ persistViewState: "session",
217
+ persistKey: listViewId,
218
+ handleNavigate,
219
+ }}
220
+ bareSettings={{
221
+ bareNewDTW: isNewDTW,
222
+ bareListMobileView: isMobile,
223
+ bareUpdateMobileView: isMobile,
224
+ }}
225
+ />
226
+ )}
227
+ </div>
228
+ </div>
229
+ );
230
+ };
231
+
232
+ export const ScrollRestore = () => (
233
+ <div className="w-full">
234
+ <div className="font-semibold mb-1 text-gray-900">
235
+ Data Template — scroll position restore
236
+ </div>
237
+ <div className="text-sm mb-4 text-gray-600">
238
+ Offline, with {RECORD_COUNT} records. Pick a view, scroll well down, open
239
+ a record, then go back to the list. You should land on the record you
240
+ opened, not the top. Each view keeps its own position. Switch between New
241
+ DTW and Legacy to cover both sets of list components, and Mobile for the
242
+ mobile table cards and kanban tabs.
243
+ </div>
244
+ <ScrollRestoreDemo />
245
+ </div>
246
+ );