blixify-ui-web-mcp 0.1.20 → 0.1.21

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.
@@ -36,6 +36,7 @@ export const SignUp = () => {
36
36
  const [signUpType, setSignUpType] =
37
37
  useState<typeof AvailableProvider | "">("");
38
38
  const [stepIndex, setStepIndex] = useState(0);
39
+ const [phone, setPhone] = useState("");
39
40
 
40
41
  const handleChangeProvider = (provider: typeof AvailableProvider | "") => {
41
42
  setSignUpType(provider);
@@ -75,10 +76,11 @@ export const SignUp = () => {
75
76
  <TextInput
76
77
  ref={input2}
77
78
  id="phoneNumber"
78
- value=""
79
+ value={phone}
79
80
  type="mobile"
80
81
  label="Mobile"
81
82
  placeholder="+60123456789"
83
+ onChange={(e) => setPhone(e.target.value)}
82
84
  />,
83
85
  <Select
84
86
  value="M"
@@ -120,6 +122,21 @@ export const SignUp = () => {
120
122
  ],
121
123
  };
122
124
 
125
+ // INFO: with the country picker on, the code comes from the selector, so the
126
+ // number field only asks for the local part; the other sections keep the full
127
+ // +60 format a plain mobile field expects
128
+ const pickerFormList = {
129
+ ...formList,
130
+ steps: formList.steps.map((eachStep) => ({
131
+ ...eachStep,
132
+ components: eachStep.components.map((eachComponent) =>
133
+ eachComponent.props?.id === "phoneNumber"
134
+ ? React.cloneElement(eachComponent, { placeholder: "123456789" })
135
+ : eachComponent
136
+ ),
137
+ })),
138
+ };
139
+
123
140
  return (
124
141
  <div className="font-sans max-w-4xl p-6">
125
142
  <StorySection
@@ -150,6 +167,27 @@ export const SignUp = () => {
150
167
  </div>
151
168
  </StorySection>
152
169
 
170
+ <StorySection
171
+ title="Phone Country Picker"
172
+ description="Enable mobilePicker and mobileCountryEditable on SignUpTemplate to apply an editable country selector to mobile TextInput fields."
173
+ >
174
+ <div className="w-full bg-white rounded-lg shadow-sm border border-gray-100 overflow-hidden">
175
+ <SignUpTemplate
176
+ type="phone"
177
+ stepIndex={0}
178
+ leftComponents={leftComponents}
179
+ leftClassName="bg-gray-50"
180
+ handleUpdateStepIndex={setStepIndex}
181
+ onChangeProvider={handleChangeProvider}
182
+ provider={["phone"]}
183
+ defaultProvider="phone"
184
+ formList={pickerFormList}
185
+ mobilePicker={true}
186
+ mobileCountryEditable={true}
187
+ />
188
+ </div>
189
+ </StorySection>
190
+
153
191
  <StorySection
154
192
  title="Colored Left Panel"
155
193
  description={
@@ -349,7 +349,9 @@ No `edit` prop — hides all create/update/delete controls. Users can browse and
349
349
 
350
350
  ### Full CRUD
351
351
 
352
- Enable all editing features. `allEditable` turns on create, update, and delete in one flag.
352
+ Enable all editing features. `allEditable` turns on create and update; delete is
353
+ granted separately by `deleteBtn`, and inline cell editing by `listEditable` —
354
+ see the Write permission matrix below.
353
355
 
354
356
  ```tsx
355
357
  <DataTemplate
@@ -561,6 +563,58 @@ Set `viewType="kanban"` on an `extendedTabs` entry and configure `kanbanSettings
561
563
 
562
564
  ---
563
565
 
566
+ ### Drive view
567
+
568
+ A Google Drive-style folder browser. Every row in the collection is a file or a folder, so a folder is a row, and moving something only changes one column. Set `viewType="drive"` on an `extendedTabs` entry and name the four columns in `driveSettings`.
569
+
570
+ - **Browse:** the open folder's rows load as one unpaged list, with folders first and then files, sorted A–Z. A path above the list goes back up. While the DTW search has text, the view searches the whole drive and shows each result's folder.
571
+ - **Folder id:** pass `organise.folderId` and `organise.onChangeFolder` to keep the open folder in the url. Without them the view keeps its own state.
572
+ - **Actions:** New folder, and Upload (one row per file, stored in that row's own storage folder). Files dragged over the drive show a "Drop to upload to <folder>" overlay, and a drop starts uploading straight away. The record Create button is hidden on a drive tab. Each item's menu has Details (the record's read view), Rename, Move to… and Delete. Deleting a folder deletes everything inside it, rows and storage.
573
+ - **Permissions:** `allEditable` or `createBtn` shows New folder and Upload. `canUpdateRecord` or `canInlineUpdate` allows Rename and Move. `deleteBtn` allows Delete. Without them the drive can be browsed but not changed.
574
+ - **Layout:** grid or list. The choice is remembered per browser, and phones start in the list layout.
575
+
576
+ ```tsx
577
+ <DataTemplate
578
+ title="Documents"
579
+ description=""
580
+ type="list"
581
+ collectionId="documents"
582
+ model={[
583
+ { id: "name", name: "Name", type: "string" },
584
+ { id: "kind", name: "Kind", type: "list", listSelection: ["folder", "file"] },
585
+ { id: "parentId", name: "Folder", type: "string" },
586
+ { id: "file", name: "File", type: "multipleFile" },
587
+ { id: "size", name: "Size", type: "number" },
588
+ ]}
589
+ localURLEndpoint=""
590
+ devSettings={devSettings}
591
+ lib={{ axios, Menu, Transition }}
592
+ edit={{ allEditable: true, listEditable: true, deleteBtn: true }}
593
+ organise={{
594
+ handleNavigate,
595
+ folderId,
596
+ onChangeFolder: setFolderId,
597
+ extendedTabs: [
598
+ {
599
+ name: "Drive",
600
+ type: "list",
601
+ viewType: "drive",
602
+ defaultTab: true,
603
+ driveSettings: {
604
+ nameColumnId: "name",
605
+ kindColumnId: "kind",
606
+ parentColumnId: "parentId",
607
+ fileColumnId: "file",
608
+ sizeColumnId: "size",
609
+ },
610
+ },
611
+ ],
612
+ }}
613
+ />
614
+ ```
615
+
616
+ ---
617
+
564
618
  ### With search and filter
565
619
 
566
620
  Use `organise.additionalQuery` to add filter controls above the list. Each entry maps to a filter UI control.
@@ -858,9 +912,9 @@ const devSettings = parseWorkspaceSettings(
858
912
 
859
913
  | Name | Type | Description |
860
914
  | ----------------------- | ------------- | -------------------------------------------------------- |
861
- | `allEditable` | `boolean` | Enables add and update, and gates inline/drag writes |
915
+ | `allEditable` | `boolean` | Enables add and update, and gates card drag and batch writes |
862
916
  | `listAddable` | `boolean` | Enables adding rows inline in list view |
863
- | `listEditable` | `boolean` | Enables inline cell editing in list view |
917
+ | `listEditable` | `boolean` | Enables inline cell editing in list view, and gates its write |
864
918
  | `createBtn` | `boolean` | Shows a create button even when `allEditable` is `false` |
865
919
  | `deleteBtn` | `boolean` | Enables delete and batch delete, independent of `allEditable` |
866
920
  | `disabledUpdate` | `boolean` | Disables update and batch update |
@@ -871,15 +925,19 @@ const devSettings = parseWorkspaceSettings(
871
925
 
872
926
  #### Write permission matrix
873
927
 
874
- Reader-reachable controls are gated by these rules. A control that is off is not
875
- rendered, and its underlying write is refused even if the callback is reached.
928
+ These rules decide whether a control is **rendered**. The inline cell, Kanban
929
+ drag, Kanban checkbox and batch callbacks re-check them before writing, so a
930
+ stray callback cannot write. Delete is render-gated only, because the delete
931
+ modal is also opened through the imperative `handleDeleteModal`.
876
932
 
877
933
  | Control | Requires |
878
934
  | -------------------------------------------------- | --------------------------------------- |
879
935
  | Kanban drag/drop and its update write | `allEditable && !disabledUpdate` |
880
- | Inline cell edit, Kanban boolean checkbox | `listEditable && !disabledUpdate` |
881
- | Read-view Update button, list Update column | `allEditable && !disabledUpdate` |
882
- | Batch update, clone, change-to-draft | `allEditable && !disabledUpdate && !disableBatchUpdate` |
936
+ | Inline cell edit and its write | `listEditable && !disabledUpdate` |
937
+ | Kanban boolean checkbox and its write | `listEditable && !disabledUpdate` |
938
+ | Read-view Update button | `allEditable && !disabledUpdate` |
939
+ | List Update column | `allEditable && !disabledUpdate`, or `bareListMultipleSelectAddtionalActions` |
940
+ | Batch update, clone, change-to-draft | `allEditable && !disabledUpdate && !disableBatchUpdate` (the batch Update button also requires a non-Firebase backend) |
883
941
  | Delete button, batch delete, map delete | `deleteBtn` (independent of `allEditable`) |
884
942
  | Map polygon select | batch-update or delete permission |
885
943
 
@@ -892,8 +950,15 @@ The imperative API is **not** gated by these rules. `handleFormSubmit`,
892
950
  `handleFormSaveDraft`, `handleDeleteModal` and `handleUploadOfflineData` are only
893
951
  reachable from consumer code, and `DataTemplateWrapper` defaults `edit` to
894
952
  `disabledUpdate: true, deleteBtn: false` — gating them would break every consumer
895
- that drives its own form without passing an `edit` prop. A consumer that does not
896
- want a write path should not render it.
953
+ that drives its own form without passing an `edit` prop. Neither is the shared
954
+ write path (`handleSubmitData`), which also serves the auth modal writing a
955
+ changed email back to the profile record. A consumer that does not want a write
956
+ path should not render it.
957
+
958
+ > Reading these rules through `DataTemplateWrapper`: it spreads your `edit` over
959
+ > its own defaults, so `edit={{ allEditable: true }}` still inherits
960
+ > `disabledUpdate: true` and yields no update affordances. Pass
961
+ > `disabledUpdate: false` explicitly to enable update, drag and batch controls.
897
962
 
898
963
  #### Inline editability matrix (`listEditable`)
899
964
 
@@ -970,7 +1035,8 @@ Which field types can be edited from the list table, and how. Types not listed u
970
1035
  | `fixedPagination` | `boolean` | Fixes the pagination bar to the bottom of the viewport |
971
1036
  | `filterComponentNewRow` | `boolean` | Renders each filter component on its own row |
972
1037
  | `addColumnEnabled` | `boolean` | Shows an add-column button in the table header |
973
- | `persistViewState` | `"session" \| "local"` | Remembers the list's sort, filters, hidden columns, page and search in `sessionStorage` / `localStorage` per collection, so a Back navigation lands where the user left |
1038
+ | `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 |
1039
+ | `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` |
974
1040
  | `groupBy` | `string` | Column ID to group list results by |
975
1041
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
976
1042
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
@@ -1083,6 +1149,8 @@ Injects custom React components and callbacks into the list, read, and update vi
1083
1149
  | `bareListTablePinColumns` | `{ left?: string[]; right?: string[] }` | Columns kept sticky at the edges while the table scrolls sideways (`"update"` is the actions column); new table only |
1084
1150
  | `bareListMobileView` | `boolean` | Phone card list instead of the table (the app passes its own isMobile) |
1085
1151
  | `bareListMobileFloatingSearch` | `boolean` | Floating search bar at the bottom on phones; the list reserves room under its last row for it |
1152
+ | `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 |
1153
+ | `bareListMobileTooltip` | `NewListMobileTooltip` | Passed to NewList when `bareNewDTW` is on: `imageKey`, `summaryKeys`, `tagKeys` and `tagColors` for the hover card on list items |
1086
1154
  | `bareListMaxSelectedCount` | `number` | Caps how many rows the checkbox column may select at once |
1087
1155
  | `bareUpdateHandleData` | `(data: any, changedAttributeIds?: string[]) => void` | Called on every form field change with the current data and changed field IDs |
1088
1156
  | `bareUpdateHandlePreComplete` | `(data: any) => Promise<boolean>` | Called before save — return `false` to cancel |
@@ -1168,6 +1236,16 @@ calendarSettings: {
1168
1236
  }
1169
1237
  ```
1170
1238
 
1239
+ ### Field visibility (`hideRead`)
1240
+
1241
+ `hideRead: true` on a DataStructure field:
1242
+
1243
+ - leaves the field out of the read view's field list;
1244
+ - on a `listObject`, `ganttchart` or `canvas` field, hides the whole field from the read view;
1245
+ - on a column inside a `listObject` (`objectData`), leaves that column out of the list-object table, in both the read and the update view. The row's edit popup still shows it, and the stored value is not changed.
1246
+
1247
+ *Pointers:* `src/components/data/dataTemplate/index.tsx`, `renderEachListObject` in `src/components/data/updateModule.tsx`
1248
+
1171
1249
  ### List / MultipleList Field Settings
1172
1250
 
1173
1251
  Properties specific to `type: "list"` and `type: "multipleList"` fields in the DataStructure schema.
@@ -1224,6 +1302,17 @@ Under `bareNewDTW` the feeds tab renders `NewFeeds` (timeline card that matches
1224
1302
  | `useColumnName` | `boolean` | Shows column name instead of column ID on cards |
1225
1303
  | `query` | `Query[]` | Additional queries to filter and organise kanban cards |
1226
1304
 
1305
+ ### Drive Settings
1306
+
1307
+ | Name | Type | Description |
1308
+ | ------------------ | -------- | ------------------------------------------------------------------------------------------------------------ |
1309
+ | `nameColumnId` | `string` | A string column holding the file or folder name |
1310
+ | `kindColumnId` | `string` | A list column whose value is `folder` or `file` |
1311
+ | `parentColumnId` | `string` | The containing folder's row id. Missing, null or `""` means the top level |
1312
+ | `fileColumnId` | `string` | A `multipleFile` column holding the one uploaded file as `{ token, fileName }`, so the original name is kept |
1313
+ | `sizeColumnId` | `string` | Optional. A number column written with the file size in bytes on upload |
1314
+ | `mimeTypeColumnId` | `string` | Optional. A string column written with the file type on upload |
1315
+
1227
1316
  ### Map Settings
1228
1317
 
1229
1318
  | Name | Type | Description |
@@ -0,0 +1,197 @@
1
+ // INFO: the drive view over an in-memory collection. The story mocks its own
2
+ // API, so it opens without a server and the e2e spec drives it as it is.
3
+
4
+ import React, { useEffect, useRef, useState } from "react";
5
+ import MockAdapter from "axios-mock-adapter";
6
+ import axios from "axios";
7
+ import { DataTemplateWrapper } from "../dataWrapper";
8
+ import { DataStructure } from "./model";
9
+ import { ExtendedTabs } from "./spaceModel";
10
+
11
+ const meta = {
12
+ title: "Data/Data Template/Drive",
13
+ decorators: [
14
+ (Story: () => React.ReactElement) => (
15
+ <div className="bg-gray-50 min-h-screen p-4">
16
+ <Story />
17
+ </div>
18
+ ),
19
+ ],
20
+ };
21
+ export default meta;
22
+
23
+ const DRIVE_ENDPOINT = "http://localhost:3000/api/data";
24
+ const DRIVE_UPLOAD = "http://localhost:3000/api/asset";
25
+ const DRIVE_COLLECTION = "demo-drive";
26
+
27
+ const DriveSchema: DataStructure[] = [
28
+ { id: "name", name: "Name", type: "string" },
29
+ { id: "kind", name: "Kind", type: "list", listSelection: ["folder", "file"] },
30
+ { id: "parentId", name: "Folder", type: "string" },
31
+ { id: "file", name: "File", type: "multipleFile" },
32
+ { id: "size", name: "Size", type: "number" },
33
+ ];
34
+
35
+ const DriveTabs: ExtendedTabs[] = [
36
+ {
37
+ name: "Drive",
38
+ type: "list",
39
+ viewType: "drive",
40
+ defaultTab: true,
41
+ driveSettings: {
42
+ nameColumnId: "name",
43
+ kindColumnId: "kind",
44
+ parentColumnId: "parentId",
45
+ fileColumnId: "file",
46
+ sizeColumnId: "size",
47
+ },
48
+ },
49
+ ];
50
+
51
+ const handleBuildDriveStore = () => [
52
+ { _id: "f-contracts", name: "Contracts", kind: "folder", parentId: "" },
53
+ { _id: "f-invoices", name: "Invoices", kind: "folder", parentId: "" },
54
+ { _id: "f-2025", name: "2025", kind: "folder", parentId: "f-invoices" },
55
+ {
56
+ _id: "d-handbook",
57
+ name: "Team handbook.pdf",
58
+ kind: "file",
59
+ parentId: "",
60
+ size: 482000,
61
+ file: [{ token: "demo", fileName: "demo-handbook.pdf" }],
62
+ },
63
+ {
64
+ _id: "d-inv-01",
65
+ name: "Invoice 01.pdf",
66
+ kind: "file",
67
+ parentId: "f-2025",
68
+ size: 91000,
69
+ file: [{ token: "demo", fileName: "demo-inv-01.pdf" }],
70
+ },
71
+ {
72
+ _id: "d-nda",
73
+ name: "Supplier NDA.docx",
74
+ kind: "file",
75
+ parentId: "f-contracts",
76
+ size: 34000,
77
+ file: [{ token: "demo", fileName: "demo-nda.docx" }],
78
+ },
79
+ ];
80
+
81
+ const handleParseBody = (config: any) => {
82
+ try {
83
+ return JSON.parse(config?.data ?? "{}");
84
+ } catch (err) {
85
+ return {};
86
+ }
87
+ };
88
+
89
+ // INFO: enough of the query language for a drive — equality, `in` with null
90
+ // matching a missing field the way Mongo does, and a name search
91
+ const handleMatchQuery = (row: any, query: any[]) =>
92
+ query.every((eachQuery) => {
93
+ const value = row[eachQuery.queryId];
94
+ if (eachQuery.type === "=") return value === eachQuery.value;
95
+ if (eachQuery.type === "in")
96
+ return (eachQuery.value ?? []).some((eachValue: any) =>
97
+ eachValue === null
98
+ ? value === undefined || value === null
99
+ : value === eachValue,
100
+ );
101
+ if (eachQuery.type === "search")
102
+ return String(row.name ?? "")
103
+ .toLowerCase()
104
+ .includes(String(eachQuery.value ?? "").toLowerCase());
105
+ return true;
106
+ });
107
+
108
+ export const Drive = () => {
109
+ const [folderId, setFolderId] = useState("");
110
+ const [mounted, setMounted] = useState(false);
111
+ const storeRef = useRef<any[]>(handleBuildDriveStore());
112
+
113
+ useEffect(() => {
114
+ const store = storeRef.current;
115
+ const mock = new MockAdapter(axios, {
116
+ onNoMatch: "passthrough",
117
+ delayResponse: 120,
118
+ });
119
+ const base = `${DRIVE_ENDPOINT}/${DRIVE_COLLECTION}`;
120
+
121
+ mock.onPost(`${base}/list`).reply((config) => {
122
+ const body = handleParseBody(config);
123
+ const rows = store.filter((eachRow) =>
124
+ handleMatchQuery(eachRow, body.query ?? []),
125
+ );
126
+ return [200, { data: rows, count: rows.length }];
127
+ });
128
+ mock.onPost(`${base}/get`).reply((config) => {
129
+ const body = handleParseBody(config);
130
+ return [200, { data: store.find((row) => row._id === body.id) ?? null }];
131
+ });
132
+ mock.onPost(`${base}/create`).reply((config) => {
133
+ store.push(handleParseBody(config).data);
134
+ return [200, { success: true }];
135
+ });
136
+ mock.onPost(`${base}/update`).reply((config) => {
137
+ const body = handleParseBody(config);
138
+ const row = store.find((eachRow) => eachRow._id === body.id);
139
+ if (row) Object.assign(row, body.data);
140
+ return [200, { success: true }];
141
+ });
142
+ mock.onPost(`${base}/batchDelete`).reply((config) => {
143
+ const ids: string[] = handleParseBody(config).id ?? [];
144
+ for (let index = store.length - 1; index >= 0; index -= 1)
145
+ if (ids.includes(store[index]._id)) store.splice(index, 1);
146
+ return [200, { success: true }];
147
+ });
148
+ mock.onPost(`${DRIVE_UPLOAD}/upload`).reply(200, { data: "demo-token" });
149
+ mock
150
+ .onPost(`${DRIVE_UPLOAD}/batchDeleteDirectory`)
151
+ .reply(200, { success: true });
152
+
153
+ setMounted(true);
154
+ return () => mock.restore();
155
+ }, []);
156
+
157
+ if (!mounted) return null;
158
+
159
+ return (
160
+ <div className="w-full">
161
+ <div className="font-semibold mb-1 text-gray-900">Drive</div>
162
+ <div className="text-sm font-light mb-4 text-gray-500">
163
+ A folder browser over a collection where every row is a file or a
164
+ folder. Open folders, use the path to go back, create, upload, rename,
165
+ move and delete. Deleting a folder takes everything inside it.
166
+ </div>
167
+ <DataTemplateWrapper
168
+ title="Documents"
169
+ type="list"
170
+ collectionId={DRIVE_COLLECTION}
171
+ schema={DriveSchema}
172
+ workspace={{
173
+ apiEndpointType: "mongoDB",
174
+ apiEndpoint: DRIVE_ENDPOINT,
175
+ apiEndpointToken: "",
176
+ apiUserToken: "",
177
+ assetEndpoint:
178
+ "https://firebasestorage.googleapis.com/v0/b/demo-bucket.firebasestorage.app/o/",
179
+ assetUploadEndpoint: DRIVE_UPLOAD,
180
+ }}
181
+ edit={{
182
+ allEditable: true,
183
+ listEditable: true,
184
+ disabledUpdate: false,
185
+ deleteBtn: true,
186
+ }}
187
+ bareSettings={{ bareNewDTW: true, bareCompactMode: true }}
188
+ organise={{
189
+ extendedTabs: DriveTabs,
190
+ folderId,
191
+ onChangeFolder: setFolderId,
192
+ }}
193
+ handleNavigate={() => {}}
194
+ />
195
+ </div>
196
+ );
197
+ };
@@ -756,3 +756,203 @@ export const DTWListObjectTable = () => {
756
756
  );
757
757
  };
758
758
  DTWListObjectTable.storyName = "DTW List Object Table";
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // DTW Callback Counts — used by e2e/dtw-callbacks.spec.ts
762
+ // ---------------------------------------------------------------------------
763
+
764
+ const CALLBACK_ENDPOINT = "https://callback-counts.blixify.invalid/api/data";
765
+ const CALLBACK_COLLECTION = "callback-records";
766
+
767
+ const CallbackCountsSchema = [
768
+ { id: "name", name: "Name", type: "string" as const },
769
+ { id: "notes", name: "Notes", type: "string" as const, optional: true },
770
+ ];
771
+
772
+ type CallbackCounterKey =
773
+ | "list"
774
+ | "get"
775
+ | "create"
776
+ | "update"
777
+ | "delete"
778
+ | "preComplete"
779
+ | "postComplete"
780
+ | "navigate";
781
+
782
+ const CALLBACK_COUNTER_KEYS: CallbackCounterKey[] = [
783
+ "list",
784
+ "get",
785
+ "create",
786
+ "update",
787
+ "delete",
788
+ "preComplete",
789
+ "postComplete",
790
+ "navigate",
791
+ ];
792
+
793
+ const handleInstallCallbackMock = (
794
+ store: any[],
795
+ onCount: (key: CallbackCounterKey) => void,
796
+ ) => {
797
+ const mock = new MockAdapter(axios, {
798
+ onNoMatch: "passthrough",
799
+ delayResponse: 100,
800
+ });
801
+ const handleParseBody = (config: any) => {
802
+ try {
803
+ return JSON.parse(config?.data ?? "{}");
804
+ } catch (err) {
805
+ return {};
806
+ }
807
+ };
808
+ const renderUrl = (type: string) =>
809
+ `${CALLBACK_ENDPOINT}/${CALLBACK_COLLECTION}/${type}`;
810
+ mock.onPost(renderUrl("list")).reply(() => {
811
+ onCount("list");
812
+ return [
813
+ 200,
814
+ { data: store.map((row) => ({ ...row })), count: store.length },
815
+ ];
816
+ });
817
+ mock
818
+ .onPost(renderUrl("count"))
819
+ .reply(() => [200, { data: store.length, count: store.length }]);
820
+ mock.onPost(renderUrl("get")).reply((config) => {
821
+ onCount("get");
822
+ const body = handleParseBody(config);
823
+ const row = store.find((eachRow) => eachRow._id === body.id);
824
+ return [200, { data: row ? { ...row } : null }];
825
+ });
826
+ mock.onPost(renderUrl("create")).reply((config) => {
827
+ onCount("create");
828
+ store.push({ ...(handleParseBody(config).data ?? {}) });
829
+ return [200, { success: true }];
830
+ });
831
+ mock.onPost(renderUrl("update")).reply((config) => {
832
+ onCount("update");
833
+ const body = handleParseBody(config);
834
+ const index = store.findIndex((eachRow) => eachRow._id === body.id);
835
+ if (index >= 0) store[index] = { ...store[index], ...(body.data ?? {}) };
836
+ return [200, { success: true }];
837
+ });
838
+ mock.onPost(renderUrl("delete")).reply((config) => {
839
+ onCount("delete");
840
+ const body = handleParseBody(config);
841
+ const index = store.findIndex((eachRow) => eachRow._id === body.id);
842
+ if (index >= 0) store.splice(index, 1);
843
+ return [200, { success: true }];
844
+ });
845
+ return mock;
846
+ };
847
+
848
+ /**
849
+ * INFO: a full CRUD page that counts every request and every save callback on
850
+ * screen, so e2e can assert that one user action fires each of them exactly
851
+ * once. A doubled callback reaches consumers as a doubled email or upload,
852
+ * while the page itself looks fine — the counters are the only visible signal.
853
+ */
854
+ export const DTWCallbackCounts = () => {
855
+ // 1. State
856
+ const [mount, setMount] = useState<boolean>(false);
857
+ const [, setTick] = useState<number>(0);
858
+ const [viewType, setViewType] = useState<"list" | "read" | "update">("list");
859
+ const [selectedId, setSelectedId] = useState<string>("");
860
+ const [lastNavigate, setLastNavigate] = useState<string>("");
861
+
862
+ // 2. Refs
863
+ const storeRef = useRef<any[]>([
864
+ { _id: "r1", name: "Record One", notes: "first" },
865
+ { _id: "r2", name: "Record Two", notes: "second" },
866
+ ]);
867
+ const countersRef = useRef<Record<CallbackCounterKey, number>>({
868
+ list: 0,
869
+ get: 0,
870
+ create: 0,
871
+ update: 0,
872
+ delete: 0,
873
+ preComplete: 0,
874
+ postComplete: 0,
875
+ navigate: 0,
876
+ });
877
+ const mockRef = useRef<any>(null);
878
+
879
+ // 4. Effects
880
+ useEffect(() => {
881
+ mockRef.current = handleInstallCallbackMock(storeRef.current, handleCount);
882
+ setMount(true);
883
+ return () => {
884
+ mockRef.current?.restore();
885
+ mockRef.current = null;
886
+ };
887
+ }, []);
888
+
889
+ // 5. Handlers
890
+ function handleCount(key: CallbackCounterKey) {
891
+ countersRef.current[key] += 1;
892
+ setTick((previous) => previous + 1);
893
+ }
894
+
895
+ const handleNavigate = (type: any, id: any) => {
896
+ handleCount("navigate");
897
+ setLastNavigate(`${type}:${id ?? ""}`);
898
+ setViewType(type);
899
+ if (id !== undefined) setSelectedId(id);
900
+ };
901
+
902
+ // 7. Return
903
+ if (!mount) return <Loading />;
904
+ return (
905
+ <div className="w-full">
906
+ <div className="font-semibold mb-1 text-gray-900">
907
+ DTW Callback Counts
908
+ </div>
909
+ <div className="text-sm font-light mb-4 text-gray-500">
910
+ Every request and save callback is counted below. One create, update or
911
+ delete should move each of its counters by exactly one.
912
+ </div>
913
+ <div className="flex flex-wrap gap-4 mb-4 text-xs text-gray-600 font-mono">
914
+ {CALLBACK_COUNTER_KEYS.map((eachKey) => (
915
+ <span key={eachKey}>
916
+ {eachKey}:{" "}
917
+ <span data-testid={`callback-count-${eachKey}`}>
918
+ {countersRef.current[eachKey]}
919
+ </span>
920
+ </span>
921
+ ))}
922
+ <span>
923
+ last navigate:{" "}
924
+ <span data-testid="callback-last-navigate">{lastNavigate}</span>
925
+ </span>
926
+ </div>
927
+ <DataTemplateWrapper
928
+ title="Records"
929
+ type={viewType}
930
+ id={selectedId}
931
+ collectionId={CALLBACK_COLLECTION}
932
+ schema={CallbackCountsSchema}
933
+ workspace={{
934
+ apiEndpointType: "mongoDB",
935
+ apiEndpoint: CALLBACK_ENDPOINT,
936
+ apiEndpointToken: "",
937
+ apiUserToken: "",
938
+ assetEndpoint: "",
939
+ assetUploadEndpoint: "",
940
+ }}
941
+ edit={{ allEditable: true, disabledUpdate: false, deleteBtn: true }}
942
+ organise={{ breadcrumbType: "button" }}
943
+ bareSettings={{
944
+ bareNewDTW: true,
945
+ bareUpdateHandlePreComplete: async () => {
946
+ handleCount("preComplete");
947
+ return true;
948
+ },
949
+ bareUpdateHandlePostComplete: async () => {
950
+ handleCount("postComplete");
951
+ },
952
+ }}
953
+ handleNavigate={handleNavigate}
954
+ />
955
+ </div>
956
+ );
957
+ };
958
+ DTWCallbackCounts.storyName = "DTW Callback Counts";
@@ -2,12 +2,6 @@
2
2
 
3
3
  Calendar surface with six variants. Mobile default should be **weekStrip + dayTimeline** (not month grid).
4
4
 
5
- ## Design reference
6
-
7
- PR #1666 — `uiRefresh.stories.tsx`, sections **Calendar — Variant 1 through Variant 5** (~line 979 `MiniMonthCalendar`, ~line 1091 `MobileWeekStrip`, ~line 1136 `DayTimeline`, etc.).
8
-
9
- Note: an earlier draft of `NewCalendar` lived at `src/components/display/newCalendar/index.tsx` (1,243 LOC) but was deleted by Sean's commit `bbd0117c` ("fix removed new stories and implemented redesign directly to components"). The full source is recoverable via `git show <sha>:src/components/display/newCalendar/index.tsx` and should be the starting point for this implementation — don't rewrite from scratch.
10
-
11
5
  ## Variants
12
6
 
13
7
  | Variant | Pattern | Best for |
@@ -2,11 +2,7 @@
2
2
 
3
3
  Social-style activity feed. Composes `NewListRow` so the row geometry matches `NewList` — same avatar size, same trailing-time position, same border treatment. The Feed-specific bits (quoted content, inline media, reactions row) attach via `renderExtra`.
4
4
 
5
- ## Design reference
6
-
7
- PR #1666 — `uiRefresh.stories.tsx`, section **"Feeds View — Modern Mobile (Social-style Activity)"** (~line 1676, `ModernFeedsView`).
8
-
9
- Patterns to honour:
5
+ ## Design patterns
10
6
 
11
7
  - Avatar + actor name + relative time on the row header
12
8
  - Quoted comment blocks with left-border accent
@@ -2,13 +2,6 @@
2
2
 
3
3
  Sectioned card board. Desktop: horizontal columns. Mobile: tab switcher between columns + swipe-card-to-move-column + FAB for primary CTA.
4
4
 
5
- ## Design reference
6
-
7
- PR #1666 — `uiRefresh.stories.tsx`:
8
-
9
- - **"Kanban — Desktop Column View"** (~line 650, `DesktopKanban`)
10
- - **"Kanban — Mobile Tab View"** (~line 675, `MobileKanban`) — already includes the tab switcher, swipe hint, and FAB pattern. Lift those into the real component.
11
-
12
5
  ## Mobile interactions
13
6
 
14
7
  - Tap a column tab to switch (no horizontal scroll)
@@ -6,11 +6,8 @@ Mobile-native list foundation. Other components (`NewFeeds`, `NewTable` mobile b
6
6
 
7
7
  `Calendar` / `Table` / `Kanban` / `List` / `Feeds` already exist as legacy components. The `New*` prefix lets us ship the modernised foundation without breaking consumers. Migration plan TBD (likely v2.0.0 deprecation).
8
8
 
9
- ## Design reference
9
+ ## Design patterns
10
10
 
11
- PR #1666 — `uiRefresh.stories.tsx`, section **"List View — Modern Mobile (Sticky Section Headers)"** (~line 1561, `ModernListView`).
12
-
13
- Key patterns to honour:
14
11
  - Sticky section headers (TODAY / YESTERDAY / THIS WEEK) — stay pinned while scrolling
15
12
  - Avatar + 2-line preview row (`NewListRow`)
16
13
  - Unread state — bold sender + small blue dot
@@ -2,15 +2,6 @@
2
2
 
3
3
  Tabular display with a List-shaped mobile breakpoint. The architectural key: **`mobileView=true` delegates row rendering to `NewListRow`**, so the same tap-rhythm and avatar/title/trailing geometry shows up across NewList, NewFeeds, and NewTable mobile.
4
4
 
5
- ## Design reference
6
-
7
- PR #1666 — `uiRefresh.stories.tsx`:
8
-
9
- - **"Table — Desktop (clean borderless)"** (~line 491, `DesktopTable`)
10
- - **"Table — Mobile Card View"** (~line 552, `MobileCardTable`)
11
-
12
- Note: the Mobile Card View mock in #1666 invented a bespoke avatar/name/status layout. That pattern is being replaced — the mobile rendering should use `NewListRow` (composition), not duplicate it.
13
-
14
5
  ## Mobile column flags
15
6
 
16
7
  Columns expose three optional booleans that map them to the `NewListItem` shape:
@@ -23,6 +14,35 @@ Columns expose three optional booleans that map them to the `NewListItem` shape:
23
14
 
24
15
  Columns flagged `hideOnMobile` are dropped from the mobile rendering entirely.
25
16
 
17
+ ## Mobile tooltip (`mobileTooltip`)
18
+
19
+ With `mobileView`, `mobileTooltip` adds row data to the collapsed row and to a hover card. Through `DataTemplate` it is `bareSettings.bareMobileTooltip`, which applies when `bareNewDTW` and `bareListMobileView` are both on.
20
+
21
+ | Key | Type | Shows |
22
+ | --------------------------- | -------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
23
+ | `titleAdjacent.tagKey` | `string` | That row field's value as a Tag next to the row title |
24
+ | `titleAdjacent.tagColor` | `Record<string, TagColor>` | The Tag colour for each value |
25
+ | `titleAdjacent.trailingKey` | `string` | That row field's value as trailing text, e.g. a price |
26
+ | `titleAdjacent.summaryKeys` | `string[]` | A second line under the title while the row is collapsed, as label + value pairs. Labels come from the column titles; a dotted key (`shippingAddress.name`) falls back to its parent column's title. Empty values are skipped. Tapping the line expands the row |
27
+ | `tooltip.imageKey` | `string` | The image URL field shown in the hover card |
28
+ | `tooltip.summaryKeys` | `string[]` | Label + value pairs in the hover card |
29
+ | `tooltip.tagKeys` | `string[]` | Tag pills in the hover card |
30
+ | `tooltip.tagColors` | `Record<string, TagColor>` | The Tag colour for each value |
31
+
32
+ ```tsx
33
+ <NewTable
34
+ mobileView
35
+ mobileTooltip={{
36
+ titleAdjacent: {
37
+ tagKey: "status",
38
+ tagColor: { Delivered: "green", Pending: "yellow" },
39
+ summaryKeys: ["shippingAddress.name"],
40
+ },
41
+ }}
42
+ {...tableProps}
43
+ />
44
+ ```
45
+
26
46
  ## Column width
27
47
 
28
48
  Drag the grip on a header's right border to resize that column. The line is
@@ -372,6 +372,85 @@ const ElementCellsMatrix = () => {
372
372
  );
373
373
  };
374
374
 
375
+ // INFO: an editable table with disabled schema columns beside editable ones —
376
+ // hovering Value or Notes opens an editor, while Unit, Source and the coloured
377
+ // Status column stay read-only, matching how the form treats disabled fields
378
+ const lockedColumns: NewTableColumn[] = [
379
+ {
380
+ key: "metric",
381
+ title: "Metric",
382
+ type: "list",
383
+ listSelection: ["Steps", "Heart Rate", "Weight"],
384
+ listSelectionColour: ["blue", "red", "yellow"],
385
+ },
386
+ { key: "value", title: "Value", type: "number" },
387
+ { key: "unit", title: "Unit (disabled)", type: "string", disabled: true },
388
+ { key: "notes", title: "Notes", type: "string" },
389
+ {
390
+ key: "source",
391
+ title: "Source (disabled)",
392
+ type: "string",
393
+ disabled: true,
394
+ },
395
+ {
396
+ key: "status",
397
+ title: "Status (disabled)",
398
+ type: "list",
399
+ listSelection: ["Synced", "Pending"],
400
+ listSelectionColour: ["green", "yellow"],
401
+ disabled: true,
402
+ },
403
+ ];
404
+
405
+ const lockedRows: NewTableRow[] = [
406
+ {
407
+ id: "1",
408
+ metric: "Heart Rate",
409
+ value: 72,
410
+ unit: "bpm",
411
+ notes: "After walk",
412
+ source: "Manual entry",
413
+ status: "Synced",
414
+ },
415
+ {
416
+ id: "2",
417
+ metric: "Weight",
418
+ value: 68.5,
419
+ unit: "kg",
420
+ notes: "Morning",
421
+ source: "Device sync",
422
+ status: "Pending",
423
+ },
424
+ ];
425
+
426
+ const EditableWithDisabledColumns = () => {
427
+ const [rows, setRows] = useState<NewTableRow[]>(lockedRows);
428
+
429
+ return (
430
+ <NewTableImpl
431
+ columns={lockedColumns}
432
+ data={rows}
433
+ paginationType="page"
434
+ size={rows.length}
435
+ limit={10}
436
+ pageIndex={0}
437
+ handleUpdatePageIndex={() => {}}
438
+ editable={{
439
+ active: true,
440
+ onUpdateData: async (rowId, columnId, value) => {
441
+ setRows((previous) =>
442
+ previous.map((eachRow) =>
443
+ eachRow.id === rowId
444
+ ? { ...eachRow, [columnId]: value }
445
+ : eachRow,
446
+ ),
447
+ );
448
+ },
449
+ }}
450
+ />
451
+ );
452
+ };
453
+
375
454
  export const NewTable = () => {
376
455
  const [selectedIds, setSelectedIds] = useState<string[]>([]);
377
456
 
@@ -512,6 +591,13 @@ export const NewTable = () => {
512
591
  <EditableWithAddColumn />
513
592
  </StorySection>
514
593
 
594
+ <StorySection
595
+ title="Editable with disabled columns"
596
+ description="A column marked disabled in the schema stays read-only in an editable table, the same as in the form. Hover Value or Notes to edit; Unit, Source and the coloured Status column never open an editor, and a row click still reaches the row."
597
+ >
598
+ <EditableWithDisabledColumns />
599
+ </StorySection>
600
+
515
601
  <StorySection
516
602
  title="Element cells with row actions (config matrix)"
517
603
  description="Cells can be React elements: kit inputs with cellInput={{ active: true }} (the same compact mode the DTW inline edit matrix uses) and IconButtons per row, while the parent owns the row state. Typed columns are what make the inline 'Create New Item' row render inputs — element-only columns render nothing there. Do NOT combine with editable.active: edit mode wraps element cells in its own editors (from 1.2.66 a React element cell passes through, but its inputs are ~286px wide). Wrap in overflow-x-auto when the columns outgrow the pane."
@@ -68,7 +68,7 @@ return (
68
68
  | `disabled` | `boolean` | |
69
69
  | `rounded` | `none / md /lg` | Border with |
70
70
  | `variant` | `flat / outline /flatOutline` | |
71
- | `color` | `red / blue / green / yellow / indigo / dark` | Background color & text color |
71
+ | `color` | `TagColor \| string` — `primary / red / blue / green / yellow / indigo / purple / lime / sky / pink / orange / dark` | Background color & text color. `TagColor` is exported from this component |
72
72
  | `customFontSize` | `string` | Tailwind font class name |
73
73
  | `customPadding` | `string` | Tailwind padding class name |
74
74
  | `onClick` | `(value: string) => void` | Value is tagText |
@@ -67,6 +67,13 @@ export const Tag = () => {
67
67
  <TagImpl tagText="React" color="dark" variant="flatOutline" />
68
68
  <TagImpl tagText="Default" color="default" variant="flatOutline" />
69
69
  </div>
70
+ <h1 className="text-sm font-medium">Custom Hex</h1>
71
+ <div className="flex flex-wrap gap-4">
72
+ <TagImpl tagText="Joel Michael" color="#2563eb" />
73
+ <TagImpl tagText="cychanyi" color="#0d9488" />
74
+ <TagImpl tagText="Backlogs" color="#a16207" />
75
+ <TagImpl tagText="Small" size="small" color="#db2777" />
76
+ </div>
70
77
  </div>
71
78
  </div>
72
79
  );
@@ -34,6 +34,10 @@ export const UploadInput = () => {
34
34
  const [multiPdfs, setMultiPdfs] = useState<any>({ ...emptyFiles });
35
35
  const [noDupFiles, setNoDupFiles] = useState<any>({ ...emptyFiles });
36
36
  const [compactFiles, setCompactFiles] = useState<any>({ ...preloadedImages });
37
+ const [chooserFiles, setChooserFiles] = useState<any>({ ...emptyFiles });
38
+ const [chooserMobileFiles, setChooserMobileFiles] = useState<any>({
39
+ ...emptyFiles,
40
+ });
37
41
  const [tagFiles, setTagFiles] = useState<any>({
38
42
  data: [
39
43
  {
@@ -104,6 +108,43 @@ export const UploadInput = () => {
104
108
 
105
109
  return (
106
110
  <>
111
+ <StorySection
112
+ title="Camera / Gallery Chooser"
113
+ description="showCameraGalleryOptionModal anchors a Camera / Gallery panel to the upload trigger. Android needs it: a WebView only reaches the camera when the input carries `capture`, so without a chooser an image field drops straight into the file picker with no camera option. iOS presents its own sheet, so consumers should gate this on platform. Camera uses capture='environment'; both honour the field's accept."
114
+ >
115
+ <div className="w-full max-w-sm">
116
+ <UploadInputImpl
117
+ lib={{ axios }}
118
+ id="chooserImages"
119
+ label="Roof Aerial / Overview Image"
120
+ type="multi"
121
+ showModal
122
+ accept="image/*"
123
+ files={chooserFiles}
124
+ onChange={(value: any) => setChooserFiles(value)}
125
+ />
126
+ </div>
127
+ </StorySection>
128
+
129
+ <StorySection
130
+ title="Camera / Gallery Chooser — Mobile Row"
131
+ description="The same chooser in the mobileView row layout, which is what a phone renders below 768px. The panel is anchored to its trigger, so it travels with the field as the form scrolls."
132
+ >
133
+ <div className="w-full max-w-sm">
134
+ <UploadInputImpl
135
+ lib={{ axios }}
136
+ id="chooserMobileImages"
137
+ label="Ridgecapping Location Image"
138
+ type="multi"
139
+ showModal
140
+ mobileView
141
+ accept="image/*,video/*"
142
+ files={chooserMobileFiles}
143
+ onChange={(value: any) => setChooserMobileFiles(value)}
144
+ />
145
+ </div>
146
+ </StorySection>
147
+
107
148
  <StorySection
108
149
  title="Single Image Upload"
109
150
  description="Default single-file mode. Accepts PNG, JPG, JPEG, HEIC, HEIF. HEIC/HEIF files are auto-converted to JPEG. compress reduces file size before upload."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {