blixify-ui-web-mcp 0.1.44 → 0.1.46

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.
@@ -679,6 +679,18 @@ Editors (`edit.allEditable`) get add, edit, remove and move on each chart; add i
679
679
 
680
680
  ---
681
681
 
682
+ ### Related records
683
+
684
+ Set `organise.showRelatedTab` to give the read view a tab for each other space with a `reference` or `listReference` column pointing at this record, beside Card View and named with its row count (e.g. `Orders (3)`). It is off by default: it shows other spaces' rows, and opening a record then runs one schema query plus one single-row count per referencing column. Spaces are found the same way the delete check finds them: through `devSettings.schemaEndpoint` (the workspace's datasheets, which needs the `space` prop) or `bareSettings.bareReferenceDel`. Only spaces with matching rows get a tab.
685
+
686
+ A tab shows that space as a read-only list Data Template (table, search, paging), filtered with `=` for a reference or `in` for a list reference. Picking a row opens it in the same tab with a Back button, so the record's own tabs stay on screen.
687
+
688
+ ```tsx
689
+ organise={{ viewId, handleNavigate, showRelatedTab: true }}
690
+ ```
691
+
692
+ ---
693
+
682
694
  ### With search and filter
683
695
 
684
696
  Use `organise.additionalQuery` to add filter controls above the list. Each entry maps to a filter UI control.
@@ -1145,6 +1157,11 @@ Which field types can be edited from the list table, and how. Types not listed u
1145
1157
  | `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` |
1146
1158
  | `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 |
1147
1159
  | `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 |
1160
+ | `sharedViews` | `SharedSavedView[]` | Views the app stores for everyone (`{ id, name, tabId, state }`), e.g. on the record the list belongs to. Given, it replaces this browser's storage: the list shows only these, saving goes to `onChangeSharedViews`, and nothing is read from or written to `localStorage` |
1161
+ | `onChangeSharedViews` | `(views: SharedSavedView[]) => void` | Called with the whole list after a view is saved, updated or deleted, for the app to store and pass back as `sharedViews`; a second change before it comes back builds on the list just sent. The whole list is replaced each time, so two editors saving at once is last-writer-wins. Without it the app's views are read-only and “+ Save view” is hidden |
1162
+ | `defaultSavedViewId` | `string` | The view everyone opens on, marked ★, stored by the app. The list opens on it when no `initialSavedViewId` is given — also when it arrives after mount — unless `persistViewState` restored a state or a view was already picked. A view that no longer exists leaves the list on All. Opening on it does not call `onChangeSort` |
1163
+ | `onChangeDefaultSavedViewId` | `(id: string \| null) => void` | Gives each view a ☆ that makes it the default; the ★ on the default takes it off (`null`, the list opens on All). Deleting the default view also sends `null` |
1164
+ | `initialSavedViewId` | `string` | A view the list opens on, once, in place of the ★ default — e.g. a page embedding a datasheet opens it on a chosen view. A view that no longer exists, or is on another tab, leaves the list on All. Opening on it does not call `onChangeSort` |
1148
1165
  | `groupBy` | `string` | Column ID to group list results by |
1149
1166
  | `groupIdCollapsible` | `{ [key: string]: boolean }` | Initial collapsed/expanded state per group ID |
1150
1167
  | `sortGroupByAlphabetically` | `boolean` | Sorts group-by values alphabetically |
@@ -1159,6 +1176,7 @@ Which field types can be edited from the list table, and how. Types not listed u
1159
1176
  | `customPaginationCount` | `(pageDataCount: number) => void` | Called after each data fetch with the actual record count. Use this to override the count shown in the pagination bar — store the value in your own state and pass it back via `customPaginationSize` on `Table`, `Grid`, `List`, or `Pagination` if needed, or use it to display a custom count elsewhere in your UI |
1160
1177
  | `onAddColumns` | `(model: DataStructure[]) => void` | Called when a new column is added |
1161
1178
  | `handleNavigate` | `(type: string, selectedId?: string, viewId?: string) => void` | Called when the component needs to navigate (e.g. row click → read view) |
1179
+ | `showRelatedTab` | `boolean` | Adds a read view tab per other space whose rows reference the record. Default `false` |
1162
1180
  | `handleCustomDownloadFile` | `(url: string, fileName?: string) => void` | Overrides the default file download behaviour |
1163
1181
  | `renderReferenceLabelOptions` | `(spaceId?: string, includeReference?: boolean, type?: string) => Option[]` | Returns column options for reference label selectors |
1164
1182
  | `renderReferenceSpaceOptions` | `() => Option[]` | Returns space options for reference space selectors |
@@ -14,7 +14,7 @@ import {
14
14
  renderDemoRecords,
15
15
  TABS,
16
16
  } from "./savedViewsDemo.fixtures";
17
- import { buildSavedViewsKey } from "./viewStatePersistence";
17
+ import { buildSavedViewsKey, SharedSavedView } from "./viewStatePersistence";
18
18
 
19
19
  const meta = { title: "Data/Data Template" };
20
20
  export default meta;
@@ -140,3 +140,146 @@ export const SavedViews = () => (
140
140
  <SavedViewsDemo />
141
141
  </div>
142
142
  );
143
+
144
+ const SharedViewsDemo = () => {
145
+ const [viewId, setViewId] = useState("general");
146
+ const [isMockReady, setIsMockReady] = useState(false);
147
+ // INFO: what an app would store on the record the list belongs to
148
+ const [sharedViews, setSharedViews] = useState<SharedSavedView[]>([
149
+ {
150
+ id: "shared-due-last",
151
+ name: "Due last",
152
+ tabId: "general",
153
+ state: { sort: { sortId: "dueDate", type: "desc" } },
154
+ },
155
+ ]);
156
+ const [defaultSavedViewId, setDefaultSavedViewId] = useState<string | null>(
157
+ null,
158
+ );
159
+ const [openOn, setOpenOn] = useState("");
160
+ const [isEditor, setIsEditor] = useState(true);
161
+ // INFO: remounts the list, as reopening the page would
162
+ const [reloadKey, setReloadKey] = useState(0);
163
+ // INFO: hands the default over a moment after mount, as an app loading it
164
+ // from its record does
165
+ const [isDefaultLoading, setIsDefaultLoading] = useState(false);
166
+
167
+ const handleReloadWithLateDefault = () => {
168
+ setIsDefaultLoading(true);
169
+ setReloadKey((prev) => prev + 1);
170
+ setTimeout(() => setIsDefaultLoading(false), 1000);
171
+ };
172
+ const store = useMemo(() => renderDemoRecords(), []);
173
+
174
+ useEffect(() => {
175
+ const mock = handleInstallDemoMock(store);
176
+ setIsMockReady(true);
177
+ return () => {
178
+ mock.restore();
179
+ setIsMockReady(false);
180
+ };
181
+ }, [store]);
182
+
183
+ return (
184
+ <div className="w-full">
185
+ <div className="mb-4 flex flex-wrap items-center gap-3">
186
+ <ButtonGroup
187
+ selection={[
188
+ { id: "editor", name: "Editor" },
189
+ { id: "reader", name: "Reader" },
190
+ ]}
191
+ selectedId={isEditor ? "editor" : "reader"}
192
+ variant="tag"
193
+ size="small"
194
+ onClick={(id) => setIsEditor(id === "editor")}
195
+ />
196
+ <select
197
+ className="rounded-md border border-gray-300 px-2 py-1 text-sm"
198
+ value={openOn}
199
+ onChange={(event) => setOpenOn(event.target.value)}
200
+ >
201
+ <option value="">Open on: ★ default</option>
202
+ <option value="missing-view">Open on: a deleted view</option>
203
+ {sharedViews.map((eachView) => (
204
+ <option key={eachView.id} value={eachView.id}>
205
+ Open on: {eachView.name}
206
+ </option>
207
+ ))}
208
+ </select>
209
+ <button
210
+ type="button"
211
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
212
+ onClick={() => setReloadKey((prev) => prev + 1)}
213
+ >
214
+ Reload
215
+ </button>
216
+ <button
217
+ type="button"
218
+ className="rounded-md border border-gray-300 bg-white px-3 py-1 text-sm text-gray-700 hover:bg-gray-50"
219
+ onClick={handleReloadWithLateDefault}
220
+ >
221
+ Reload, default arrives late
222
+ </button>
223
+ </div>
224
+ {isMockReady && (
225
+ <DataTemplateImpl
226
+ key={`${openOn}-${isEditor}-${reloadKey}`}
227
+ title="Tasks"
228
+ description="40 offline records"
229
+ type="list"
230
+ collectionId={DEMO_COLLECTION}
231
+ model={DEMO_SCHEMA}
232
+ localURLEndpoint=""
233
+ devSettings={demoDevSettings}
234
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
235
+ limit={10}
236
+ organise={{
237
+ extendedTabs: TABS,
238
+ viewId,
239
+ savedViews: true,
240
+ sharedViews,
241
+ onChangeSharedViews: isEditor ? setSharedViews : undefined,
242
+ defaultSavedViewId: isDefaultLoading
243
+ ? undefined
244
+ : (defaultSavedViewId ?? undefined),
245
+ onChangeDefaultSavedViewId: isEditor
246
+ ? setDefaultSavedViewId
247
+ : undefined,
248
+ initialSavedViewId: openOn || undefined,
249
+ handleNavigate: (type: string, _id?: string, view?: string) => {
250
+ if (type === "list" && view) setViewId(view);
251
+ },
252
+ }}
253
+ bareSettings={{ bareNewDTW: true, bareFlexFilters: true }}
254
+ />
255
+ )}
256
+ <pre
257
+ data-testid="shared-views-store"
258
+ className="mt-4 max-h-48 overflow-auto rounded bg-gray-50 p-3 text-xs text-gray-600"
259
+ >
260
+ {JSON.stringify({ defaultSavedViewId, views: sharedViews }, null, 2)}
261
+ </pre>
262
+ </div>
263
+ );
264
+ };
265
+
266
+ export const SharedSavedViews = () => (
267
+ <div className="w-full">
268
+ <div className="font-semibold mb-1 text-gray-900">
269
+ Data Template — views stored by the app
270
+ </div>
271
+ <div className="text-sm mb-4 text-gray-600">
272
+ With sharedViews, views come from the app — here story state, shown below
273
+ — instead of this browser, so the whole team sees the same ones. As Editor
274
+ (onChangeSharedViews and onChangeDefaultSavedViewId given), “+ Save view”
275
+ hands the list to the app, and the ☆ on a view makes it the ★ default
276
+ everyone opens on; ★ again takes it off, leaving All. As Reader the views
277
+ are used, the default shows ★, and nothing can be changed. “Open on” sets
278
+ initialSavedViewId, how a page embedding a datasheet opens it on a chosen
279
+ view; a deleted view opens on All. “Reload” remounts the list, and
280
+ “default arrives late” hands the ★ over a second after mount, as an app
281
+ loading it does.
282
+ </div>
283
+ <SharedViewsDemo />
284
+ </div>
285
+ );
@@ -752,3 +752,275 @@ export const ChartView = () => (
752
752
  <ChartViewDemo />
753
753
  </div>
754
754
  );
755
+
756
+ // INFO: a Customers space that three other datasheets point at — Orders with a
757
+ // reference column, Support Tickets with a list reference, and Invoices whose
758
+ // rows reference other customers only. Notes holds a text column that names
759
+ // the space but is not a reference, which the Related tab must ignore.
760
+ const RELATED_WORKSPACE = "demo-workspace";
761
+ const RELATED_CUSTOMERS = "demo-customers";
762
+
763
+ const RELATED_CUSTOMER_SCHEMA: DataStructure[] = [
764
+ { id: "name", name: "Customer", type: "string" },
765
+ { id: "email", name: "Email", type: "email" },
766
+ {
767
+ id: "tier",
768
+ name: "Tier",
769
+ type: "list",
770
+ listSelection: ["standard", "gold"],
771
+ listSelectionLabel: ["Standard", "Gold"],
772
+ },
773
+ ];
774
+
775
+ const RELATED_SPACES: any[] = [
776
+ {
777
+ _id: "demo-orders",
778
+ name: "Orders",
779
+ type: "datasheet",
780
+ workspaceId: RELATED_WORKSPACE,
781
+ structure: [
782
+ { id: "orderNo", name: "Order No", type: "string" },
783
+ {
784
+ id: "status",
785
+ name: "Status",
786
+ type: "list",
787
+ listSelection: ["pending", "paid", "shipped"],
788
+ listSelectionLabel: ["Pending", "Paid", "Shipped"],
789
+ },
790
+ { id: "amount", name: "Amount", type: "number" },
791
+ { id: "createdAt", name: "Created At", type: "date" },
792
+ {
793
+ id: "customerId",
794
+ name: "Customer",
795
+ type: "reference",
796
+ referenceSpaceId: RELATED_CUSTOMERS,
797
+ referenceLabel: "name",
798
+ },
799
+ ],
800
+ },
801
+ {
802
+ _id: "demo-tickets",
803
+ name: "Support Tickets",
804
+ type: "datasheet",
805
+ workspaceId: RELATED_WORKSPACE,
806
+ structure: [
807
+ { id: "subject", name: "Subject", type: "string" },
808
+ {
809
+ id: "priority",
810
+ name: "Priority",
811
+ type: "list",
812
+ listSelection: ["low", "high"],
813
+ listSelectionLabel: ["Low", "High"],
814
+ },
815
+ { id: "openedAt", name: "Opened", type: "date" },
816
+ {
817
+ id: "customerIds",
818
+ name: "Customers",
819
+ type: "listReference",
820
+ referenceSpaceId: RELATED_CUSTOMERS,
821
+ referenceLabel: "name",
822
+ },
823
+ ],
824
+ },
825
+ {
826
+ _id: "demo-invoices",
827
+ name: "Invoices",
828
+ type: "datasheet",
829
+ workspaceId: RELATED_WORKSPACE,
830
+ structure: [
831
+ { id: "invoiceNo", name: "Invoice No", type: "string" },
832
+ { id: "total", name: "Total", type: "number" },
833
+ {
834
+ id: "customerId",
835
+ name: "Customer",
836
+ type: "reference",
837
+ referenceSpaceId: RELATED_CUSTOMERS,
838
+ referenceLabel: "name",
839
+ },
840
+ ],
841
+ },
842
+ {
843
+ _id: "demo-notes",
844
+ name: "Notes",
845
+ type: "datasheet",
846
+ workspaceId: RELATED_WORKSPACE,
847
+ structure: [
848
+ { id: "text", name: "Text", type: "string" },
849
+ {
850
+ id: "customerName",
851
+ name: "Customer Name",
852
+ type: "string",
853
+ referenceSpaceId: RELATED_CUSTOMERS,
854
+ },
855
+ ],
856
+ },
857
+ ];
858
+
859
+ const renderRelatedRecords = (): { [collection: string]: any[] } => {
860
+ const day = (offset: number) =>
861
+ new Date(Date.UTC(2026, 9, 12 - offset, 9)).toISOString();
862
+ return {
863
+ [RELATED_CUSTOMERS]: [
864
+ {
865
+ _id: "c1",
866
+ name: "Northwind Traders",
867
+ email: "orders@northwind.example",
868
+ tier: "gold",
869
+ },
870
+ {
871
+ _id: "c2",
872
+ name: "Contoso Retail",
873
+ email: "hello@contoso.example",
874
+ tier: "standard",
875
+ },
876
+ ],
877
+ "demo-orders": [
878
+ ["o1", "ORD-1001", "paid", 420, 1, "c1"],
879
+ ["o2", "ORD-1002", "shipped", 1280, 3, "c1"],
880
+ ["o3", "ORD-1003", "pending", 95, 5, "c2"],
881
+ ["o4", "ORD-1004", "pending", 310, 6, "c1"],
882
+ ].map(([_id, orderNo, status, amount, ago, customerId]) => ({
883
+ _id,
884
+ orderNo,
885
+ status,
886
+ amount,
887
+ createdAt: day(ago as number),
888
+ customerId,
889
+ })),
890
+ "demo-tickets": [
891
+ {
892
+ _id: "t1",
893
+ subject: "Late delivery",
894
+ priority: "high",
895
+ openedAt: day(2),
896
+ customerIds: ["c1"],
897
+ },
898
+ {
899
+ _id: "t2",
900
+ subject: "Invoice copy",
901
+ priority: "low",
902
+ openedAt: day(4),
903
+ customerIds: ["c1", "c2"],
904
+ },
905
+ ],
906
+ "demo-invoices": [
907
+ { _id: "i1", invoiceNo: "INV-77", total: 95, customerId: "c2" },
908
+ ],
909
+ "demo-notes": [{ _id: "n1", text: "Call back", customerName: "c1" }],
910
+ };
911
+ };
912
+
913
+ const handleInstallRelatedMock = (store: { [collection: string]: any[] }) => {
914
+ const mock = new MockAdapter(axios, {
915
+ onNoMatch: "passthrough",
916
+ delayResponse: 120,
917
+ });
918
+ const handleParseBody = (config: any) => {
919
+ try {
920
+ return JSON.parse(config?.data ?? "{}");
921
+ } catch (err) {
922
+ return {};
923
+ }
924
+ };
925
+ // INFO: "=" for a reference column, "in" for a list reference — the two
926
+ // queries the Related tab sends
927
+ const handleMatch = (record: any, query: any[] = []) =>
928
+ query.every((eachQuery) => {
929
+ const value = record[eachQuery.queryId];
930
+ if (eachQuery.type === "=") return value === eachQuery.value;
931
+ if (eachQuery.type === "in") {
932
+ const values = Array.isArray(value) ? value : [value];
933
+ return values.some((eachValue) => eachQuery.value.includes(eachValue));
934
+ }
935
+ return true;
936
+ });
937
+
938
+ mock.onPost(`${DEMO_ENDPOINT}/spaces/list`).reply(200, {
939
+ data: RELATED_SPACES,
940
+ count: RELATED_SPACES.length,
941
+ });
942
+
943
+ mock.onPost(new RegExp(`${DEMO_ENDPOINT}/([^/]+)/list$`)).reply((config) => {
944
+ const collection = config.url?.split("/").slice(-2)[0] ?? "";
945
+ const body = handleParseBody(config);
946
+ const rows = (store[collection] ?? []).filter((eachRecord) =>
947
+ handleMatch(eachRecord, body.query),
948
+ );
949
+ const limit = typeof body.limit === "number" ? body.limit : 10;
950
+ return [200, { data: rows.slice(0, limit), count: rows.length }];
951
+ });
952
+
953
+ mock.onPost(new RegExp(`${DEMO_ENDPOINT}/([^/]+)/get$`)).reply((config) => {
954
+ const collection = config.url?.split("/").slice(-2)[0] ?? "";
955
+ const body = handleParseBody(config);
956
+ return [
957
+ 200,
958
+ {
959
+ data:
960
+ (store[collection] ?? []).find(
961
+ (eachRecord) => eachRecord._id === body.id,
962
+ ) ?? null,
963
+ },
964
+ ];
965
+ });
966
+
967
+ return mock;
968
+ };
969
+
970
+ const RelatedRecordsDemo = () => {
971
+ const store = React.useMemo(() => renderRelatedRecords(), []);
972
+ React.useMemo(() => handleInstallRelatedMock(store), [store]);
973
+
974
+ const [typeParam, setTypeParam] = useState<"list" | "read" | "update">(
975
+ "read",
976
+ );
977
+ const [dataId, setDataId] = useState<string>("c1");
978
+ const [viewId, setViewId] = useState<string | undefined>(undefined);
979
+
980
+ return (
981
+ <div className="w-full">
982
+ <DataTemplateImpl
983
+ title="Customers"
984
+ type={typeParam}
985
+ id={dataId}
986
+ collectionId={RELATED_CUSTOMERS}
987
+ model={RELATED_CUSTOMER_SCHEMA}
988
+ localURLEndpoint=""
989
+ devSettings={demoDevSettings}
990
+ lib={{ axios, Menu, Transition, papaParser: Papa }}
991
+ edit={{ deleteBtn: true }}
992
+ space={
993
+ {
994
+ _id: RELATED_CUSTOMERS,
995
+ name: "Customers",
996
+ workspaceId: RELATED_WORKSPACE,
997
+ } as any
998
+ }
999
+ organise={{
1000
+ viewId,
1001
+ showRelatedTab: true,
1002
+ handleNavigate: (type: string, id?: string, view?: string) => {
1003
+ setTypeParam(type as any);
1004
+ setDataId(id ?? "");
1005
+ setViewId(view);
1006
+ },
1007
+ }}
1008
+ />
1009
+ </div>
1010
+ );
1011
+ };
1012
+
1013
+ export const RelatedRecords = () => (
1014
+ <div className="w-full">
1015
+ <div className="font-semibold mb-1 text-gray-900">
1016
+ Data Template — related records
1017
+ </div>
1018
+ <div className="text-sm mb-4 text-gray-600">
1019
+ The read view adds a tab for each other space whose <code>reference</code>{" "}
1020
+ or <code>listReference</code> column points at this record. A tab lists
1021
+ those rows; a row opens in place with Back to the list, and the record's
1022
+ tabs stay on screen.
1023
+ </div>
1024
+ <RelatedRecordsDemo />
1025
+ </div>
1026
+ );
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "blixify-ui-web-mcp",
3
- "version": "0.1.44",
3
+ "version": "0.1.46",
4
4
  "license": "MIT",
5
5
  "main": "lib/mcp/server.js",
6
6
  "bin": {