blixify-ui-web-mcp 0.1.20 → 0.1.22
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/auth/signUp/signUp.stories.tsx +39 -1
- package/lib/components/data/dataTemplate/README.md +37 -11
- package/lib/components/data/dataWrapper/dataWrapper.edit.stories.tsx +200 -0
- package/lib/components/data/driveTemplate/README.md +90 -0
- package/lib/components/data/driveTemplate/driveTemplate.stories.tsx +177 -0
- package/lib/components/display/newCalendar/README.md +0 -6
- package/lib/components/display/newFeeds/README.md +1 -5
- package/lib/components/display/newKanban/README.md +0 -7
- package/lib/components/display/newList/README.md +1 -4
- package/lib/components/display/newTable/README.md +29 -9
- package/lib/components/display/newTable/newTable.stories.tsx +86 -0
- package/lib/components/display/tag/README.md +1 -1
- package/lib/components/display/tag/tag.stories.tsx +7 -0
- package/lib/components/input/uploadInput/uploadInput.stories.tsx +41 -0
- package/lib/mcp/tools/getBackendComponent.d.ts.map +1 -1
- package/lib/mcp/tools/getBackendComponent.js +4 -0
- package/lib/mcp/tools/getBackendComponent.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
@@ -858,9 +860,9 @@ const devSettings = parseWorkspaceSettings(
|
|
|
858
860
|
|
|
859
861
|
| Name | Type | Description |
|
|
860
862
|
| ----------------------- | ------------- | -------------------------------------------------------- |
|
|
861
|
-
| `allEditable` | `boolean` | Enables add and update, and gates
|
|
863
|
+
| `allEditable` | `boolean` | Enables add and update, and gates card drag and batch writes |
|
|
862
864
|
| `listAddable` | `boolean` | Enables adding rows inline in list view |
|
|
863
|
-
| `listEditable` | `boolean` | Enables inline cell editing in list view
|
|
865
|
+
| `listEditable` | `boolean` | Enables inline cell editing in list view, and gates its write |
|
|
864
866
|
| `createBtn` | `boolean` | Shows a create button even when `allEditable` is `false` |
|
|
865
867
|
| `deleteBtn` | `boolean` | Enables delete and batch delete, independent of `allEditable` |
|
|
866
868
|
| `disabledUpdate` | `boolean` | Disables update and batch update |
|
|
@@ -871,15 +873,19 @@ const devSettings = parseWorkspaceSettings(
|
|
|
871
873
|
|
|
872
874
|
#### Write permission matrix
|
|
873
875
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
+
These rules decide whether a control is **rendered**. The inline cell, Kanban
|
|
877
|
+
drag, Kanban checkbox and batch callbacks re-check them before writing, so a
|
|
878
|
+
stray callback cannot write. Delete is render-gated only, because the delete
|
|
879
|
+
modal is also opened through the imperative `handleDeleteModal`.
|
|
876
880
|
|
|
877
881
|
| Control | Requires |
|
|
878
882
|
| -------------------------------------------------- | --------------------------------------- |
|
|
879
883
|
| Kanban drag/drop and its update write | `allEditable && !disabledUpdate` |
|
|
880
|
-
| Inline cell edit
|
|
881
|
-
|
|
|
882
|
-
|
|
|
884
|
+
| Inline cell edit and its write | `listEditable && !disabledUpdate` |
|
|
885
|
+
| Kanban boolean checkbox and its write | `listEditable && !disabledUpdate` |
|
|
886
|
+
| Read-view Update button | `allEditable && !disabledUpdate` |
|
|
887
|
+
| List Update column | `allEditable && !disabledUpdate`, or `bareListMultipleSelectAddtionalActions` |
|
|
888
|
+
| Batch update, clone, change-to-draft | `allEditable && !disabledUpdate && !disableBatchUpdate` (the batch Update button also requires a non-Firebase backend) |
|
|
883
889
|
| Delete button, batch delete, map delete | `deleteBtn` (independent of `allEditable`) |
|
|
884
890
|
| Map polygon select | batch-update or delete permission |
|
|
885
891
|
|
|
@@ -892,8 +898,15 @@ The imperative API is **not** gated by these rules. `handleFormSubmit`,
|
|
|
892
898
|
`handleFormSaveDraft`, `handleDeleteModal` and `handleUploadOfflineData` are only
|
|
893
899
|
reachable from consumer code, and `DataTemplateWrapper` defaults `edit` to
|
|
894
900
|
`disabledUpdate: true, deleteBtn: false` — gating them would break every consumer
|
|
895
|
-
that drives its own form without passing an `edit` prop.
|
|
896
|
-
|
|
901
|
+
that drives its own form without passing an `edit` prop. Neither is the shared
|
|
902
|
+
write path (`handleSubmitData`), which also serves the auth modal writing a
|
|
903
|
+
changed email back to the profile record. A consumer that does not want a write
|
|
904
|
+
path should not render it.
|
|
905
|
+
|
|
906
|
+
> Reading these rules through `DataTemplateWrapper`: it spreads your `edit` over
|
|
907
|
+
> its own defaults, so `edit={{ allEditable: true }}` still inherits
|
|
908
|
+
> `disabledUpdate: true` and yields no update affordances. Pass
|
|
909
|
+
> `disabledUpdate: false` explicitly to enable update, drag and batch controls.
|
|
897
910
|
|
|
898
911
|
#### Inline editability matrix (`listEditable`)
|
|
899
912
|
|
|
@@ -970,7 +983,8 @@ Which field types can be edited from the list table, and how. Types not listed u
|
|
|
970
983
|
| `fixedPagination` | `boolean` | Fixes the pagination bar to the bottom of the viewport |
|
|
971
984
|
| `filterComponentNewRow` | `boolean` | Renders each filter component on its own row |
|
|
972
985
|
| `addColumnEnabled` | `boolean` | Shows an add-column button in the table header |
|
|
973
|
-
| `persistViewState` | `"
|
|
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 |
|
|
987
|
+
| `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
988
|
| `groupBy` | `string` | Column ID to group list results by |
|
|
975
989
|
| `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
|
|
976
990
|
| `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
|
|
@@ -1083,6 +1097,8 @@ Injects custom React components and callbacks into the list, read, and update vi
|
|
|
1083
1097
|
| `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
1098
|
| `bareListMobileView` | `boolean` | Phone card list instead of the table (the app passes its own isMobile) |
|
|
1085
1099
|
| `bareListMobileFloatingSearch` | `boolean` | Floating search bar at the bottom on phones; the list reserves room under its last row for it |
|
|
1100
|
+
| `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
|
+
| `bareListMobileTooltip` | `NewListMobileTooltip` | Passed to NewList when `bareNewDTW` is on: `imageKey`, `summaryKeys`, `tagKeys` and `tagColors` for the hover card on list items |
|
|
1086
1102
|
| `bareListMaxSelectedCount` | `number` | Caps how many rows the checkbox column may select at once |
|
|
1087
1103
|
| `bareUpdateHandleData` | `(data: any, changedAttributeIds?: string[]) => void` | Called on every form field change with the current data and changed field IDs |
|
|
1088
1104
|
| `bareUpdateHandlePreComplete` | `(data: any) => Promise<boolean>` | Called before save — return `false` to cancel |
|
|
@@ -1168,6 +1184,16 @@ calendarSettings: {
|
|
|
1168
1184
|
}
|
|
1169
1185
|
```
|
|
1170
1186
|
|
|
1187
|
+
### Field visibility (`hideRead`)
|
|
1188
|
+
|
|
1189
|
+
`hideRead: true` on a DataStructure field:
|
|
1190
|
+
|
|
1191
|
+
- leaves the field out of the read view's field list;
|
|
1192
|
+
- on a `listObject`, `ganttchart` or `canvas` field, hides the whole field from the read view;
|
|
1193
|
+
- 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.
|
|
1194
|
+
|
|
1195
|
+
*Pointers:* `src/components/data/dataTemplate/index.tsx`, `renderEachListObject` in `src/components/data/updateModule.tsx`
|
|
1196
|
+
|
|
1171
1197
|
### List / MultipleList Field Settings
|
|
1172
1198
|
|
|
1173
1199
|
Properties specific to `type: "list"` and `type: "multipleList"` fields in the DataStructure schema.
|
|
@@ -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";
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# Drive Template
|
|
2
|
+
|
|
3
|
+
<!-- STORY -->
|
|
4
|
+
|
|
5
|
+
<hr>
|
|
6
|
+
|
|
7
|
+
A Google Drive-style folder browser over a collection whose rows are files and folders. It is a standalone template, not a DataTemplate view: a drive has one fixed data structure (`DriveSchema`), no tabs, no table and no column editing.
|
|
8
|
+
|
|
9
|
+
- **Header:** the same row DataTemplate draws above a list — `title` and `description` on the left (`text-xl font-medium` / `text-sm text-gray-500`), then a labelled Search box, the grid/list toggle, New folder and Upload as icon buttons.
|
|
10
|
+
- **Browse:** the open folder loads as one unpaged list, folders first and then files, sorted A–Z. A path above the list goes back up. Grid or list layout, remembered per collection; phones start in list.
|
|
11
|
+
- **Search:** the header Search box (debounced 400ms) searches names across the whole drive and shows each result's folder. Opening a folder from the results clears the search.
|
|
12
|
+
- **New folder:** Enter and a phone keyboard's Done both create it. An empty name, or a name already used by a folder in the same place, is refused.
|
|
13
|
+
- **Upload:** pick files with Upload, or drop files onto the drive. A drop starts uploading at once. Each file is stored at `<collectionId>/<newRowId>/<uuid>-<name>`, then its row is created with `file: [{ token, fileName }]`, `size` and `mimeType`. One failed file does not stop the rest, and the dialog cannot be closed until every file has finished.
|
|
14
|
+
- **Item menu:** Rename and Move to… (`permissions.update`), and Delete (`permissions.delete`). Move never offers the item itself or a folder inside it. Deleting a folder counts its subtree one level at a time (by `parentId`, id and folder columns only), then deletes every row below it and their storage folders. With `additionalQuery` set, a folder that holds rows outside that slice, or whose full contents cannot be read, is refused, so no row is left under a folder that no longer exists.
|
|
15
|
+
- **Preview:** images, PDFs and videos open inline with size and type; every file has Download.
|
|
16
|
+
- **Backend:** reads and writes go through `ReadQuery` / `WriteQuery` built from `devSettings`. The top level is queried as `parentId in [null, ""]`, which relies on Mongo matching a missing field against `null`.
|
|
17
|
+
|
|
18
|
+
##### Import
|
|
19
|
+
|
|
20
|
+
```js
|
|
21
|
+
import {
|
|
22
|
+
DriveTemplate,
|
|
23
|
+
DriveSchema,
|
|
24
|
+
} from "blixify-ui-web/lib/components/data/driveTemplate";
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
##### Usage
|
|
28
|
+
|
|
29
|
+
```jsx
|
|
30
|
+
const [folderId, setFolderId] = useState("");
|
|
31
|
+
|
|
32
|
+
<DriveTemplate
|
|
33
|
+
title="Team Documents"
|
|
34
|
+
description="Contracts, invoices and handbooks"
|
|
35
|
+
collectionId="team-documents"
|
|
36
|
+
devSettings={{
|
|
37
|
+
server: "mongoServer",
|
|
38
|
+
dbEndpoint: "https://api.example.com/api/data",
|
|
39
|
+
dbAPIToken: "",
|
|
40
|
+
dbUserToken: "",
|
|
41
|
+
schemaEndpoint: "",
|
|
42
|
+
imageEndpoint:
|
|
43
|
+
"https://firebasestorage.googleapis.com/v0/b/demo-bucket.firebasestorage.app/o/",
|
|
44
|
+
uploadEndpoint: "https://api.example.com/api/asset",
|
|
45
|
+
}}
|
|
46
|
+
lib={{ axios }}
|
|
47
|
+
permissions={{ create: true, update: true, delete: true }}
|
|
48
|
+
folderId={folderId}
|
|
49
|
+
onChangeFolder={setFolderId}
|
|
50
|
+
/>;
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A member limited to a slice of the drive:
|
|
54
|
+
|
|
55
|
+
```jsx
|
|
56
|
+
<DriveTemplate
|
|
57
|
+
{...driveProps}
|
|
58
|
+
additionalQuery={[{ type: "=", queryId: "teamId", value: "team-a" }]}
|
|
59
|
+
createPrefill={[{ key: "teamId", value: "team-a" }]}
|
|
60
|
+
/>
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
##### Data structure
|
|
64
|
+
|
|
65
|
+
Every drive collection stores rows in this shape. `DriveSchema` is exported as a `DataStructure[]`, and `DRIVE_COLUMNS` holds the ids.
|
|
66
|
+
|
|
67
|
+
| Column | Type | Description |
|
|
68
|
+
| ---------- | ------------ | ------------------------------------------------------------------------ |
|
|
69
|
+
| `name` | string | The file or folder name shown in the drive |
|
|
70
|
+
| `kind` | list | `folder` or `file` |
|
|
71
|
+
| `parentId` | string | The containing folder's row id; missing, `null` or `""` is the top level |
|
|
72
|
+
| `file` | multipleFile | The one upload as `[{ token, fileName }]`; empty for a folder |
|
|
73
|
+
| `size` | number | File size in bytes, written on upload |
|
|
74
|
+
| `mimeType` | string | File type, written on upload |
|
|
75
|
+
|
|
76
|
+
##### Props
|
|
77
|
+
|
|
78
|
+
| Name | Type | Description |
|
|
79
|
+
| ----------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- |
|
|
80
|
+
| `title` | string | Heading drawn like DataTemplate's list title |
|
|
81
|
+
| `description` | string | Sub heading under the title |
|
|
82
|
+
| `collectionId` | string | The collection holding the drive's rows. Also the storage folder name for uploads |
|
|
83
|
+
| `devSettings` | `DevSettings` | Connection settings, as DataTemplate takes them: `server`, `dbEndpoint`, tokens, `imageEndpoint`, `uploadEndpoint`, `bucketName` |
|
|
84
|
+
| `lib` | `{ axios }` | The axios instance used for reads, writes, uploads and storage deletes |
|
|
85
|
+
| `additionalQuery` | `Query[]` | Added to every read, so a scoped member stays in their slice. A folder holding rows outside the slice cannot be deleted |
|
|
86
|
+
| `createPrefill` | `{ key: string; value: any }[]` | Values written onto every folder and file the drive creates |
|
|
87
|
+
| `permissions` | `{ create?: boolean; update?: boolean; delete?: boolean }` | `create` shows New folder, Upload and drop; `update` allows Rename and Move; `delete` allows Delete. All default to off |
|
|
88
|
+
| `folderId` | string | The open folder. Pass with `onChangeFolder` to keep it in the url |
|
|
89
|
+
| `onChangeFolder` | `(folderId: string) => void` | Called when a folder is opened or the path is used. Without it the drive keeps its own state |
|
|
90
|
+
| `darkMode` | boolean | Dark text, surfaces, menus and dialogs throughout the drive, as DataTemplate's `organise.darkMode` |
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// INFO: the drive over an in-memory collection. The story mocks its own API,
|
|
2
|
+
// 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 { DevSettings } from "../dataTemplate/model";
|
|
8
|
+
import { DriveTemplate } from ".";
|
|
9
|
+
|
|
10
|
+
const meta = {
|
|
11
|
+
title: "Data/Drive Template",
|
|
12
|
+
decorators: [
|
|
13
|
+
(Story: () => React.ReactElement) => (
|
|
14
|
+
<div className="bg-gray-50 min-h-screen p-4">
|
|
15
|
+
<Story />
|
|
16
|
+
</div>
|
|
17
|
+
),
|
|
18
|
+
],
|
|
19
|
+
};
|
|
20
|
+
export default meta;
|
|
21
|
+
|
|
22
|
+
const DRIVE_ENDPOINT = "http://localhost:3000/api/data";
|
|
23
|
+
const DRIVE_UPLOAD = "http://localhost:3000/api/asset";
|
|
24
|
+
const DRIVE_COLLECTION = "demo-drive";
|
|
25
|
+
|
|
26
|
+
const DRIVE_DEV_SETTINGS: DevSettings = {
|
|
27
|
+
server: "mongoServer",
|
|
28
|
+
dbEndpoint: DRIVE_ENDPOINT,
|
|
29
|
+
dbAPIToken: "",
|
|
30
|
+
dbUserToken: "",
|
|
31
|
+
schemaEndpoint: "",
|
|
32
|
+
imageEndpoint:
|
|
33
|
+
"https://firebasestorage.googleapis.com/v0/b/demo-bucket.firebasestorage.app/o/",
|
|
34
|
+
uploadEndpoint: DRIVE_UPLOAD,
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const handleBuildDriveStore = () => [
|
|
38
|
+
{ _id: "f-contracts", name: "Contracts", kind: "folder", parentId: "" },
|
|
39
|
+
{ _id: "f-invoices", name: "Invoices", kind: "folder", parentId: "" },
|
|
40
|
+
{ _id: "f-2025", name: "2025", kind: "folder", parentId: "f-invoices" },
|
|
41
|
+
{
|
|
42
|
+
_id: "d-handbook",
|
|
43
|
+
name: "Team handbook.pdf",
|
|
44
|
+
kind: "file",
|
|
45
|
+
parentId: "",
|
|
46
|
+
size: 482000,
|
|
47
|
+
file: [{ token: "demo", fileName: "demo-handbook.pdf" }],
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
_id: "d-inv-01",
|
|
51
|
+
name: "Invoice 01.pdf",
|
|
52
|
+
kind: "file",
|
|
53
|
+
parentId: "f-2025",
|
|
54
|
+
size: 91000,
|
|
55
|
+
file: [{ token: "demo", fileName: "demo-inv-01.pdf" }],
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
_id: "d-nda",
|
|
59
|
+
name: "Supplier NDA.docx",
|
|
60
|
+
kind: "file",
|
|
61
|
+
parentId: "f-contracts",
|
|
62
|
+
size: 34000,
|
|
63
|
+
file: [{ token: "demo", fileName: "demo-nda.docx" }],
|
|
64
|
+
},
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const handleParseBody = (config: any) => {
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(config?.data ?? "{}");
|
|
70
|
+
} catch (err) {
|
|
71
|
+
return {};
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
// INFO: enough of the query language for a drive — equality, `in` with null
|
|
76
|
+
// matching a missing field the way Mongo does, and a name search
|
|
77
|
+
const handleMatchQuery = (row: any, query: any[]) =>
|
|
78
|
+
query.every((eachQuery) => {
|
|
79
|
+
const value = row[eachQuery.queryId];
|
|
80
|
+
if (eachQuery.type === "=") return value === eachQuery.value;
|
|
81
|
+
if (eachQuery.type === "in")
|
|
82
|
+
return (eachQuery.value ?? []).some((eachValue: any) =>
|
|
83
|
+
eachValue === null
|
|
84
|
+
? value === undefined || value === null
|
|
85
|
+
: value === eachValue,
|
|
86
|
+
);
|
|
87
|
+
if (eachQuery.type === "search")
|
|
88
|
+
return String(row.name ?? "")
|
|
89
|
+
.toLowerCase()
|
|
90
|
+
.includes(String(eachQuery.value ?? "").toLowerCase());
|
|
91
|
+
return true;
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const DriveStory = (storyProps: { darkMode?: boolean }) => {
|
|
95
|
+
const [folderId, setFolderId] = useState("");
|
|
96
|
+
const [mounted, setMounted] = useState(false);
|
|
97
|
+
const storeRef = useRef<any[]>(handleBuildDriveStore());
|
|
98
|
+
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
const store = storeRef.current;
|
|
101
|
+
const mock = new MockAdapter(axios, {
|
|
102
|
+
onNoMatch: "passthrough",
|
|
103
|
+
delayResponse: 120,
|
|
104
|
+
});
|
|
105
|
+
const base = `${DRIVE_ENDPOINT}/${DRIVE_COLLECTION}`;
|
|
106
|
+
|
|
107
|
+
mock.onPost(`${base}/list`).reply((config) => {
|
|
108
|
+
const body = handleParseBody(config);
|
|
109
|
+
const rows = store.filter((eachRow) =>
|
|
110
|
+
handleMatchQuery(eachRow, body.query ?? []),
|
|
111
|
+
);
|
|
112
|
+
return [200, { data: rows, count: rows.length }];
|
|
113
|
+
});
|
|
114
|
+
mock.onPost(`${base}/get`).reply((config) => {
|
|
115
|
+
const body = handleParseBody(config);
|
|
116
|
+
return [200, { data: store.find((row) => row._id === body.id) ?? null }];
|
|
117
|
+
});
|
|
118
|
+
mock.onPost(`${base}/create`).reply((config) => {
|
|
119
|
+
store.push(handleParseBody(config).data);
|
|
120
|
+
return [200, { success: true }];
|
|
121
|
+
});
|
|
122
|
+
mock.onPost(`${base}/update`).reply((config) => {
|
|
123
|
+
const body = handleParseBody(config);
|
|
124
|
+
const row = store.find((eachRow) => eachRow._id === body.id);
|
|
125
|
+
if (row) Object.assign(row, body.data);
|
|
126
|
+
return [200, { success: true }];
|
|
127
|
+
});
|
|
128
|
+
mock.onPost(`${base}/batchDelete`).reply((config) => {
|
|
129
|
+
const ids: string[] = handleParseBody(config).id ?? [];
|
|
130
|
+
for (let index = store.length - 1; index >= 0; index -= 1)
|
|
131
|
+
if (ids.includes(store[index]._id)) store.splice(index, 1);
|
|
132
|
+
return [200, { success: true }];
|
|
133
|
+
});
|
|
134
|
+
mock.onPost(`${DRIVE_UPLOAD}/upload`).reply(200, { data: "demo-token" });
|
|
135
|
+
mock
|
|
136
|
+
.onPost(`${DRIVE_UPLOAD}/batchDeleteDirectory`)
|
|
137
|
+
.reply(200, { success: true });
|
|
138
|
+
|
|
139
|
+
setMounted(true);
|
|
140
|
+
return () => mock.restore();
|
|
141
|
+
}, []);
|
|
142
|
+
|
|
143
|
+
if (!mounted) return null;
|
|
144
|
+
|
|
145
|
+
return (
|
|
146
|
+
<div
|
|
147
|
+
className={`w-full ${storyProps.darkMode ? "rounded-lg bg-gray-900 p-4" : ""}`}
|
|
148
|
+
>
|
|
149
|
+
<div
|
|
150
|
+
className={`font-semibold mb-1 ${storyProps.darkMode ? "text-white" : "text-gray-900"}`}
|
|
151
|
+
>
|
|
152
|
+
Drive
|
|
153
|
+
</div>
|
|
154
|
+
<div className="text-sm font-light mb-4 text-gray-500">
|
|
155
|
+
A folder browser over a collection where every row is a file or a
|
|
156
|
+
folder, in the fixed DriveSchema shape. Open folders, use the path to go
|
|
157
|
+
back, search, create, upload, rename, move and delete. Deleting a folder
|
|
158
|
+
takes everything inside it.
|
|
159
|
+
</div>
|
|
160
|
+
<DriveTemplate
|
|
161
|
+
title="Documents"
|
|
162
|
+
description="Team contracts, invoices and handbooks"
|
|
163
|
+
collectionId={DRIVE_COLLECTION}
|
|
164
|
+
devSettings={DRIVE_DEV_SETTINGS}
|
|
165
|
+
lib={{ axios }}
|
|
166
|
+
permissions={{ create: true, update: true, delete: true }}
|
|
167
|
+
folderId={folderId}
|
|
168
|
+
onChangeFolder={setFolderId}
|
|
169
|
+
darkMode={storyProps.darkMode}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
export const Drive = () => <DriveStory />;
|
|
176
|
+
|
|
177
|
+
export const DriveDarkMode = () => <DriveStory darkMode={true} />;
|
|
@@ -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
|
|
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
|
|
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."
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"getBackendComponent.d.ts","sourceRoot":"","sources":["../../../src/mcp/tools/getBackendComponent.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;
|
|
1
|
+
{"version":3,"file":"getBackendComponent.d.ts","sourceRoot":"","sources":["../../../src/mcp/tools/getBackendComponent.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AA6DxB,eAAO,MAAM,uBAAuB;;;;;;;;;;CA6DnC,CAAC"}
|
|
@@ -43,6 +43,10 @@ const BACKEND_COMPONENTS = {
|
|
|
43
43
|
dir: path.join(DATA_DIR, "dataTemplate"),
|
|
44
44
|
description: "Lower-level data component driven by a Space object from the Blixify backend. Use dataWrapper instead for custom integrations.",
|
|
45
45
|
},
|
|
46
|
+
driveTemplate: {
|
|
47
|
+
dir: path.join(DATA_DIR, "driveTemplate"),
|
|
48
|
+
description: "Standalone Google Drive-style folder browser over a collection of files and folders with a fixed DriveSchema — browse, search, upload, rename, move and delete.",
|
|
49
|
+
},
|
|
46
50
|
queryForm: {
|
|
47
51
|
dir: path.join(DATA_DIR, "queryForm"),
|
|
48
52
|
description: "Standalone filter/query form component for building custom search UIs.",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"getBackendComponent.js","sourceRoot":"","sources":["../../../src/mcp/tools/getBackendComponent.ts"],"names":[],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"getBackendComponent.js","sourceRoot":"","sources":["../../../src/mcp/tools/getBackendComponent.ts"],"names":[],"mappings":"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"}
|