blixify-ui-web-mcp 0.1.71 → 0.1.75
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/chart/barChart/README.md +2 -0
- package/lib/components/chart/barChart/barChart.stories.tsx +86 -3
- package/lib/components/chart/pieChart/README.md +3 -1
- package/lib/components/chart/pieChart/pieChart.stories.tsx +18 -1
- package/lib/components/data/dataGraph/README.md +18 -1
- package/lib/components/data/dataTemplate/README.md +72 -4
- package/lib/components/data/dataTemplate/activityModal.stories.tsx +52 -0
- package/lib/components/data/dataTemplate/dataTemplate.groupedBoard.stories.tsx +259 -0
- package/lib/components/data/dataTemplate/dataTemplate.stories.tsx +162 -0
- package/lib/components/data/dataWrapper/README.md +45 -3
- package/lib/components/data/flexFilter/flexFilterRelated.stories.tsx +182 -0
- package/lib/components/data/readQuery/README.md +8 -8
- package/lib/components/display/drawer/README.md +1 -1
- package/lib/components/display/modal/README.md +1 -0
- package/lib/components/input/textInput/README.md +30 -30
- package/lib/components/input/textInput/textinput.stories.tsx +30 -0
- package/lib/components/navigation/leftNav/README.md +2 -1
- package/lib/components/navigation/leftNav/leftNav.stories.tsx +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { Menu, Transition } from "@headlessui/react";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import MockAdapter from "axios-mock-adapter";
|
|
4
|
+
import React, { useMemo, useState } from "react";
|
|
5
|
+
import { DataTemplate as DataTemplateImpl } from ".";
|
|
6
|
+
import { SharedSavedView } from "./dataStateModel";
|
|
7
|
+
import { DataStructure } from "./model";
|
|
8
|
+
|
|
9
|
+
const meta = { title: "Data/Data Template" };
|
|
10
|
+
export default meta;
|
|
11
|
+
|
|
12
|
+
const ENDPOINT = "https://grouped-board.local/api";
|
|
13
|
+
const COLLECTION = "grouped-board";
|
|
14
|
+
|
|
15
|
+
// INFO: the group sizes of a real 148,936-row leads board grouped by state —
|
|
16
|
+
// a few very large groups, many small ones, blanks and stray spellings that
|
|
17
|
+
// fall under Unknown
|
|
18
|
+
const STATE_SIZES: [string | null, number][] = [
|
|
19
|
+
["Selangor", 63116],
|
|
20
|
+
["Johor", 18340],
|
|
21
|
+
["Kuala Lumpur", 14488],
|
|
22
|
+
["Penang", 11831],
|
|
23
|
+
["Perak", 9141],
|
|
24
|
+
["Negeri Sembilan", 7690],
|
|
25
|
+
["Kedah", 6460],
|
|
26
|
+
["Melaka", 6119],
|
|
27
|
+
["", 5014],
|
|
28
|
+
["Pahang", 2226],
|
|
29
|
+
[null, 2108],
|
|
30
|
+
["Perlis", 904],
|
|
31
|
+
["Kelantan", 533],
|
|
32
|
+
["Terengganu", 409],
|
|
33
|
+
["Kuantan", 221],
|
|
34
|
+
["Putrajaya", 182],
|
|
35
|
+
["Sabah", 38],
|
|
36
|
+
["Sarawak", 32],
|
|
37
|
+
["Puchong", 16],
|
|
38
|
+
["Pulau Pinang", 12],
|
|
39
|
+
["Pahang (Kuantan)", 11],
|
|
40
|
+
["Kedah (Exclude Langkawi)", 9],
|
|
41
|
+
["Cyberjaya", 9],
|
|
42
|
+
["KL", 8],
|
|
43
|
+
["Negri Sembilan", 7],
|
|
44
|
+
["Pahang (non-Kuantan)", 7],
|
|
45
|
+
["Ipoh", 3],
|
|
46
|
+
["Labuan", 1],
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
const STATES = STATE_SIZES.map(([state]) => state).filter(
|
|
50
|
+
(state): state is string => !!state,
|
|
51
|
+
);
|
|
52
|
+
const STATUSES = ["New Lead", "Contacted", "Site Survey", "Closed", "Dropped"];
|
|
53
|
+
|
|
54
|
+
const SCHEMA: DataStructure[] = [
|
|
55
|
+
{ id: "name", name: "Name", type: "string" },
|
|
56
|
+
{ id: "area", name: "State (Area)", type: "list", listSelection: STATES },
|
|
57
|
+
{ id: "status", name: "Status", type: "list", listSelection: STATUSES },
|
|
58
|
+
{ id: "createdAt", name: "Created At", type: "date" },
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
// INFO: rows interleaved across states, so the first page of the whole list
|
|
62
|
+
// holds a few states and one Pahang row — every other band has to load itself
|
|
63
|
+
const handleBuildRows = () => {
|
|
64
|
+
const pools = STATE_SIZES.map(([state, size]) => ({ state, left: size }));
|
|
65
|
+
const total = pools.reduce((sum, pool) => sum + pool.left, 0);
|
|
66
|
+
const rows: any[] = [];
|
|
67
|
+
let index = 0;
|
|
68
|
+
while (rows.length < total) {
|
|
69
|
+
const pool = pools[index % pools.length];
|
|
70
|
+
index++;
|
|
71
|
+
if (pool.left === 0) continue;
|
|
72
|
+
pool.left--;
|
|
73
|
+
const number = String(rows.length + 1).padStart(6, "0");
|
|
74
|
+
rows.push({
|
|
75
|
+
_id: `lead-${number}`,
|
|
76
|
+
name: `Load Test Lead ${number}`,
|
|
77
|
+
...(pool.state === null ? {} : { area: pool.state }),
|
|
78
|
+
status: STATUSES[rows.length % STATUSES.length],
|
|
79
|
+
createdAt: new Date(2025, 0, 1, 0, rows.length).toISOString(),
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return rows;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
const handleIsIn = (list: any[], value: any) =>
|
|
86
|
+
list.some((each) =>
|
|
87
|
+
each === null ? value === null || value === undefined : each === value,
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
const handleMatch = (row: any, query: any[]) =>
|
|
91
|
+
(Array.isArray(query) ? query : []).every((each) => {
|
|
92
|
+
if (!each?.queryId) return true;
|
|
93
|
+
const value = row[each.queryId];
|
|
94
|
+
if (each.type === "=") return value === each.value;
|
|
95
|
+
if (each.type === "in") return handleIsIn(each.value, value);
|
|
96
|
+
if (each.type === "!in") return !handleIsIn(each.value, value);
|
|
97
|
+
return true;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// INFO: answers band reads, the whole-list read and the grouped count the way
|
|
101
|
+
// the list endpoint does, after a short delay so loading can be watched
|
|
102
|
+
const handleInstallMock = (rows: any[]) => {
|
|
103
|
+
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
|
|
104
|
+
// INFO: e2e reads how many band reads were ever in flight at once
|
|
105
|
+
const tracker = ((window as any).__groupedBoardReads = {
|
|
106
|
+
inFlight: 0,
|
|
107
|
+
maxInFlight: 0,
|
|
108
|
+
});
|
|
109
|
+
const handleDelay = () => new Promise((resolve) => setTimeout(resolve, 400));
|
|
110
|
+
mock.onPost(`${ENDPOINT}/${COLLECTION}/list`).reply(async (config) => {
|
|
111
|
+
const body = JSON.parse(config.data ?? "{}");
|
|
112
|
+
const isBandRead =
|
|
113
|
+
!body.aggregate &&
|
|
114
|
+
(body.query ?? []).some((each: any) => each?.queryId === "area");
|
|
115
|
+
if (isBandRead) {
|
|
116
|
+
tracker.inFlight += 1;
|
|
117
|
+
tracker.maxInFlight = Math.max(tracker.maxInFlight, tracker.inFlight);
|
|
118
|
+
}
|
|
119
|
+
await handleDelay();
|
|
120
|
+
if (isBandRead) tracker.inFlight -= 1;
|
|
121
|
+
const matched = rows.filter((row) => handleMatch(row, body.query));
|
|
122
|
+
if (body.aggregate?.type === "count") {
|
|
123
|
+
const counts = new Map<any, number>();
|
|
124
|
+
matched.forEach((row) => {
|
|
125
|
+
const key = row[body.aggregate.groupId] ?? null;
|
|
126
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
127
|
+
});
|
|
128
|
+
return [
|
|
129
|
+
200,
|
|
130
|
+
{
|
|
131
|
+
data: [
|
|
132
|
+
{
|
|
133
|
+
_id: "range",
|
|
134
|
+
value: Array.from(counts, ([key, value]) => ({
|
|
135
|
+
_id: key,
|
|
136
|
+
value,
|
|
137
|
+
})),
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
},
|
|
141
|
+
];
|
|
142
|
+
}
|
|
143
|
+
if (body.aggregate) return [200, { data: [] }];
|
|
144
|
+
const cursor = typeof body.cursor === "number" ? body.cursor : 0;
|
|
145
|
+
const limit = typeof body.limit === "number" ? body.limit : 50;
|
|
146
|
+
return [
|
|
147
|
+
200,
|
|
148
|
+
{ data: matched.slice(cursor, cursor + limit), count: matched.length },
|
|
149
|
+
];
|
|
150
|
+
});
|
|
151
|
+
// INFO: a row typed into a band's "Add item" row joins the front of the list
|
|
152
|
+
mock.onPost(`${ENDPOINT}/${COLLECTION}/create`).reply((config) => {
|
|
153
|
+
const body = JSON.parse(config.data ?? "{}");
|
|
154
|
+
const row = { _id: `lead-new-${rows.length + 1}`, ...(body.data ?? {}) };
|
|
155
|
+
rows.unshift(row);
|
|
156
|
+
return [200, { data: row._id }];
|
|
157
|
+
});
|
|
158
|
+
mock.onPost(`${ENDPOINT}/${COLLECTION}/get`).reply((config) => {
|
|
159
|
+
const body = JSON.parse(config.data ?? "{}");
|
|
160
|
+
return [200, { data: rows.find((row) => row._id === body.id) ?? null }];
|
|
161
|
+
});
|
|
162
|
+
return mock;
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const VIEW_NAMES = [
|
|
166
|
+
"Closed Case",
|
|
167
|
+
"Drawing",
|
|
168
|
+
"Hunting & Referral",
|
|
169
|
+
"Marketing",
|
|
170
|
+
"1.60 Nuqman's Lead Board · Closed Case",
|
|
171
|
+
"1.60 Nuqman's Lead Board · Drawing",
|
|
172
|
+
"1.60 Nuqman's Lead Board · Marketing",
|
|
173
|
+
"1.59 Mei Jun's Lead Board · Closed Case",
|
|
174
|
+
"1.59 Mei Jun's Lead Board · Drawing",
|
|
175
|
+
"1.59 Mei Jun's Lead Board · Marketing",
|
|
176
|
+
"1.49 Hazim Suhaimi's Lead Board · Closed Case",
|
|
177
|
+
"1.49 Hazim Suhaimi's Lead Board · FB & Google",
|
|
178
|
+
"1.49 Hazim Suhaimi's Lead Board · Drawing",
|
|
179
|
+
"1.42 Wang's Lead Board · Closed Case",
|
|
180
|
+
"1.42 Wang's Lead Board · FB & Google",
|
|
181
|
+
"1.42 Wang's Lead Board · Drawing",
|
|
182
|
+
];
|
|
183
|
+
|
|
184
|
+
const GroupedBoardDemo = () => {
|
|
185
|
+
const rows = useMemo(() => handleBuildRows(), []);
|
|
186
|
+
useMemo(() => handleInstallMock(rows), [rows]);
|
|
187
|
+
const [sharedViews, setSharedViews] = useState<SharedSavedView[]>(() => [
|
|
188
|
+
{
|
|
189
|
+
id: "closed-only",
|
|
190
|
+
name: "Closed only",
|
|
191
|
+
tabId: "general",
|
|
192
|
+
state: {
|
|
193
|
+
flexFilters: [
|
|
194
|
+
{ id: "f1", columnId: "status", operator: "=", value: "Closed" },
|
|
195
|
+
],
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
...VIEW_NAMES.map((name, index) => ({
|
|
199
|
+
id: `view-${index}`,
|
|
200
|
+
name,
|
|
201
|
+
tabId: "general",
|
|
202
|
+
state: {},
|
|
203
|
+
})),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<DataTemplateImpl
|
|
208
|
+
title="Master Leads"
|
|
209
|
+
description="148,936 leads grouped by state"
|
|
210
|
+
type="list"
|
|
211
|
+
collectionId={COLLECTION}
|
|
212
|
+
model={SCHEMA}
|
|
213
|
+
localURLEndpoint=""
|
|
214
|
+
devSettings={{
|
|
215
|
+
server: "mongoServer",
|
|
216
|
+
dbEndpoint: ENDPOINT,
|
|
217
|
+
dbAPIToken: "",
|
|
218
|
+
dbUserToken: "",
|
|
219
|
+
schemaEndpoint: "",
|
|
220
|
+
imageEndpoint: "",
|
|
221
|
+
}}
|
|
222
|
+
lib={{ axios, Menu, Transition }}
|
|
223
|
+
edit={{ listAddable: true }}
|
|
224
|
+
limit={50}
|
|
225
|
+
bareSettings={{ bareNewDTW: true, bareFlexFilters: true }}
|
|
226
|
+
organise={{
|
|
227
|
+
viewId: "general",
|
|
228
|
+
datasheetDefaults: true,
|
|
229
|
+
showGroupByFeature: true,
|
|
230
|
+
groupBy: "area",
|
|
231
|
+
paginationType: "scroll",
|
|
232
|
+
savedViews: true,
|
|
233
|
+
sharedViews,
|
|
234
|
+
onChangeSharedViews: setSharedViews,
|
|
235
|
+
renderGroupByOptions: () => [
|
|
236
|
+
{ key: "area", label: "State (Area)" },
|
|
237
|
+
{ key: "status", label: "Status" },
|
|
238
|
+
],
|
|
239
|
+
}}
|
|
240
|
+
/>
|
|
241
|
+
);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const GroupedLargeBoard = () => (
|
|
245
|
+
<div className="w-full">
|
|
246
|
+
<div className="mb-1 font-semibold text-gray-900">
|
|
247
|
+
Data Template — a large board grouped by a list column
|
|
248
|
+
</div>
|
|
249
|
+
<div className="mb-4 text-sm text-gray-600">
|
|
250
|
+
148,936 mock leads across 28 state values (blanks included), sized like a
|
|
251
|
+
real leads board, grouped by State in scroll mode. Each read waits 400 ms.
|
|
252
|
+
Scroll up and down: every band shows its full total from the start, bands
|
|
253
|
+
not read yet draw no "Add item" rows, and bands load one at a time. The 16
|
|
254
|
+
views fold behind "+N more"; the pencil on a view renames it, and "Closed
|
|
255
|
+
only" filters every band.
|
|
256
|
+
</div>
|
|
257
|
+
<GroupedBoardDemo />
|
|
258
|
+
</div>
|
|
259
|
+
);
|
|
@@ -1368,3 +1368,165 @@ export const GroupByPagePagination = () => (
|
|
|
1368
1368
|
<GroupByScrollPaginationDemo paginationType="page" />
|
|
1369
1369
|
</div>
|
|
1370
1370
|
);
|
|
1371
|
+
|
|
1372
|
+
const DEFAULTS_COLLECTION = "demo-workspace-defaults";
|
|
1373
|
+
const DEFAULTS_MEMBERS = [
|
|
1374
|
+
{ key: "u1", label: "Aisha Rahman" },
|
|
1375
|
+
{ key: "u2", label: "Ben Tan" },
|
|
1376
|
+
];
|
|
1377
|
+
const DEFAULTS_SCHEMA: DataStructure[] = [
|
|
1378
|
+
{ id: "name", name: "Name", type: "string" },
|
|
1379
|
+
{
|
|
1380
|
+
id: "stage",
|
|
1381
|
+
name: "Stage",
|
|
1382
|
+
type: "list",
|
|
1383
|
+
listSelection: ["New", "Quoted", "Won"],
|
|
1384
|
+
optional: true,
|
|
1385
|
+
},
|
|
1386
|
+
{
|
|
1387
|
+
id: "status",
|
|
1388
|
+
name: "Status",
|
|
1389
|
+
type: "list",
|
|
1390
|
+
listSelection: ["Open", "Waiting", "Closed"],
|
|
1391
|
+
optional: true,
|
|
1392
|
+
},
|
|
1393
|
+
{ id: "owner", name: "Owner", type: "person", optional: true },
|
|
1394
|
+
{ id: "amount", name: "Amount", type: "number", optional: true },
|
|
1395
|
+
{ id: "due", name: "Due", type: "date", optional: true },
|
|
1396
|
+
];
|
|
1397
|
+
|
|
1398
|
+
const handleInstallDefaultsMock = (store: any[]) => {
|
|
1399
|
+
const mock = new MockAdapter(axios, { onNoMatch: "passthrough" });
|
|
1400
|
+
const renderUrl = (type: string) =>
|
|
1401
|
+
`${DEMO_ENDPOINT}/${DEFAULTS_COLLECTION}/${type}`;
|
|
1402
|
+
const handleParse = (config: any) => JSON.parse(config?.data ?? "{}");
|
|
1403
|
+
const handleMatch = (row: any, query: any[]) =>
|
|
1404
|
+
(query ?? []).every((eachQuery: any) => {
|
|
1405
|
+
const value = row[eachQuery.queryId];
|
|
1406
|
+
const handleIn = (list: any[]) =>
|
|
1407
|
+
list.some((each) => (each === null ? value == null : each === value));
|
|
1408
|
+
if (eachQuery.type === "=") return value === eachQuery.value;
|
|
1409
|
+
if (eachQuery.type === "in") return handleIn(eachQuery.value);
|
|
1410
|
+
if (eachQuery.type === "!in") return !handleIn(eachQuery.value);
|
|
1411
|
+
return true;
|
|
1412
|
+
});
|
|
1413
|
+
mock.onPost(renderUrl("list")).reply((config) => {
|
|
1414
|
+
const body = handleParse(config);
|
|
1415
|
+
const rows = store.filter((each) => handleMatch(each, body.query));
|
|
1416
|
+
// INFO: a grouped sum, in the shape the list endpoint answers one with
|
|
1417
|
+
if (body.aggregate?.type === "sum") {
|
|
1418
|
+
const totals: { [key: string]: any } = {};
|
|
1419
|
+
rows.forEach((each) => {
|
|
1420
|
+
const key = each[body.aggregate.groupId] ?? null;
|
|
1421
|
+
const total = (totals[String(key)] = totals[String(key)] ?? {
|
|
1422
|
+
_id: key,
|
|
1423
|
+
value: 0,
|
|
1424
|
+
});
|
|
1425
|
+
total.value += Number(each[body.aggregate.queryId]) || 0;
|
|
1426
|
+
});
|
|
1427
|
+
return [200, { data: [{ _id: "range", value: Object.values(totals) }] }];
|
|
1428
|
+
}
|
|
1429
|
+
return [200, { data: rows, count: rows.length }];
|
|
1430
|
+
});
|
|
1431
|
+
mock.onPost(renderUrl("create")).reply((config) => {
|
|
1432
|
+
const row = { ...handleParse(config).data, _id: `r${store.length + 1}` };
|
|
1433
|
+
store.push(row);
|
|
1434
|
+
(window as any).__defaultsWrites = [
|
|
1435
|
+
...((window as any).__defaultsWrites ?? []),
|
|
1436
|
+
{ type: "create", data: row },
|
|
1437
|
+
];
|
|
1438
|
+
return [200, { success: true, data: row }];
|
|
1439
|
+
});
|
|
1440
|
+
mock.onPost(renderUrl("update")).reply((config) => {
|
|
1441
|
+
const body = handleParse(config);
|
|
1442
|
+
const row = store.find((each) => each._id === body.id);
|
|
1443
|
+
if (row) Object.assign(row, body.data);
|
|
1444
|
+
(window as any).__defaultsWrites = [
|
|
1445
|
+
...((window as any).__defaultsWrites ?? []),
|
|
1446
|
+
{ type: "update", id: body.id, data: body.data },
|
|
1447
|
+
];
|
|
1448
|
+
return [200, { success: true }];
|
|
1449
|
+
});
|
|
1450
|
+
mock
|
|
1451
|
+
.onPost(`${DEMO_ENDPOINT}/spaces/list`)
|
|
1452
|
+
.reply(200, { data: [], count: 0 });
|
|
1453
|
+
return mock;
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
const WorkspaceDefaultsDemo = () => {
|
|
1457
|
+
const store = React.useMemo(
|
|
1458
|
+
() => [
|
|
1459
|
+
{
|
|
1460
|
+
_id: "r1",
|
|
1461
|
+
name: "Rooftop survey",
|
|
1462
|
+
stage: "New",
|
|
1463
|
+
status: "Open",
|
|
1464
|
+
owner: "u1",
|
|
1465
|
+
amount: 1200,
|
|
1466
|
+
due: new Date(Date.now() - 2 * 86400000).toISOString(),
|
|
1467
|
+
},
|
|
1468
|
+
{
|
|
1469
|
+
_id: "r2",
|
|
1470
|
+
name: "Inverter quote",
|
|
1471
|
+
stage: "Quoted",
|
|
1472
|
+
status: "Waiting",
|
|
1473
|
+
owner: "u2",
|
|
1474
|
+
amount: 5400.5,
|
|
1475
|
+
due: new Date().toISOString(),
|
|
1476
|
+
},
|
|
1477
|
+
{
|
|
1478
|
+
_id: "r3",
|
|
1479
|
+
name: "Panel install",
|
|
1480
|
+
stage: "Won",
|
|
1481
|
+
status: "Closed",
|
|
1482
|
+
owner: "u1",
|
|
1483
|
+
amount: 18000,
|
|
1484
|
+
},
|
|
1485
|
+
],
|
|
1486
|
+
[],
|
|
1487
|
+
);
|
|
1488
|
+
React.useMemo(() => handleInstallDefaultsMock(store), [store]);
|
|
1489
|
+
|
|
1490
|
+
return (
|
|
1491
|
+
<DataTemplateImpl
|
|
1492
|
+
title="Workspace defaults"
|
|
1493
|
+
description=""
|
|
1494
|
+
type="list"
|
|
1495
|
+
collectionId={DEFAULTS_COLLECTION}
|
|
1496
|
+
model={DEFAULTS_SCHEMA}
|
|
1497
|
+
localURLEndpoint=""
|
|
1498
|
+
devSettings={{ ...demoDevSettings, organisationMembers: DEFAULTS_MEMBERS }}
|
|
1499
|
+
lib={{ axios, Menu, Transition, papaParser: Papa }}
|
|
1500
|
+
edit={{
|
|
1501
|
+
allEditable: true,
|
|
1502
|
+
listAddable: true,
|
|
1503
|
+
activityLog: {
|
|
1504
|
+
enableLogs: true,
|
|
1505
|
+
logsCollectionId: `${DEFAULTS_COLLECTION}-logs`,
|
|
1506
|
+
currentUserId: "u1",
|
|
1507
|
+
},
|
|
1508
|
+
}}
|
|
1509
|
+
bareSettings={{ bareNewDTW: true }}
|
|
1510
|
+
organise={{
|
|
1511
|
+
groupBy: "stage",
|
|
1512
|
+
showGroupByFeature: true,
|
|
1513
|
+
dateWarnings: true,
|
|
1514
|
+
datasheetDefaults: true,
|
|
1515
|
+
renderGroupByOptions: () => [
|
|
1516
|
+
{ key: "stage", label: "Stage" },
|
|
1517
|
+
{ key: "owner", label: "Owner" },
|
|
1518
|
+
],
|
|
1519
|
+
onFetchCommentCounts: async () => ({ r1: 3 }),
|
|
1520
|
+
}}
|
|
1521
|
+
/>
|
|
1522
|
+
);
|
|
1523
|
+
};
|
|
1524
|
+
|
|
1525
|
+
export const WorkspaceDefaults = () => (
|
|
1526
|
+
<div className="w-full">
|
|
1527
|
+
<div className="font-semibold mb-1 text-gray-900">
|
|
1528
|
+
Data Template — workspace defaults
|
|
1529
|
+
</div>
|
|
1530
|
+
<WorkspaceDefaultsDemo />
|
|
1531
|
+
</div>
|
|
1532
|
+
);
|
|
@@ -287,9 +287,40 @@ bareSettings={{
|
|
|
287
287
|
| Less Than 2 | `crew.1` does not exist |
|
|
288
288
|
| Between 2 and 5 | `crew.1` exists, `crew.5` does not |
|
|
289
289
|
|
|
290
|
-
Two conditions on one list may each be met by a different row.
|
|
290
|
+
Two conditions on one list may each be met by a different row.
|
|
291
291
|
|
|
292
|
-
|
|
292
|
+
**Filtering on related rows and their count**
|
|
293
|
+
|
|
294
|
+
`bareFlexFilterRelated` names other collections whose rows point to these rows, such as a referrer's referrals. Each entry has a `name`, its `collection`, the column there that points here (`foreignField`), the column here it matches (`localField`: `_id` for a reference column, or a contact column), and its `structure`. The key becomes part of the lookup alias, so it may hold only letters, digits, `_` and `-`. The lookup names a variable after `localField`, so it may hold only letters, digits and `_`. An entry that breaks either rule is left out of the filter.
|
|
295
|
+
|
|
296
|
+
The filters then offer `<Name> › <Field>` and `<Name> › Count`. The count is the number of that collection's rows pointing to the record that match the conditions on its fields. Field conditions with no count condition ask for at least one such row.
|
|
297
|
+
|
|
298
|
+
```tsx
|
|
299
|
+
bareSettings={{
|
|
300
|
+
bareFlexFilters: true,
|
|
301
|
+
bareFlexFilterRelated: {
|
|
302
|
+
referrals: {
|
|
303
|
+
name: "Referrals",
|
|
304
|
+
collection: "referrals",
|
|
305
|
+
localField: "phone",
|
|
306
|
+
foreignField: "referrerPhone",
|
|
307
|
+
structure: ReferralSchema,
|
|
308
|
+
},
|
|
309
|
+
},
|
|
310
|
+
}}
|
|
311
|
+
|
|
312
|
+
// "Referrals › Referral Date between 1 and 31 Oct" with "Referrals › Count
|
|
313
|
+
// Greater Than 3" is sent as one condition on the row count:
|
|
314
|
+
// { type: ">", queryId: "rel_referrals_count", value: 3,
|
|
315
|
+
// related: { collection: "referrals", localField: "phone", foreignField: "referrerPhone",
|
|
316
|
+
// alias: "rel_referrals", query: [{ type: "><", queryId: "referralDate", value: [...] }] } }
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
`ReadQuery` sends a `related` condition as a `pipeline` lookup with `referenceShowCount`, which MongoWrapper joins and counts before its `$match`, and leaves `rel_<key>_count` out of the rows it returns. The match is exact, so a contact column only matches a phone number stored in the same format. The lookup runs for every row the list's own read scope allows, before its other conditions, so index `foreignField` on a large collection: without it each row scans the whole related collection.
|
|
320
|
+
|
|
321
|
+
Referenced records, listObjects and related rows are not offered on Firebase or `unionCollections` lists.
|
|
322
|
+
|
|
323
|
+
The standalone `FlexFilter` takes all three as `extras={{ references, listObjects: true, related }}`; pass the same object to `renderFlexFilterConditions` when building the query.
|
|
293
324
|
|
|
294
325
|
**8. newTabUrl (Optional)**
|
|
295
326
|
|
|
@@ -1226,13 +1257,24 @@ needs to know about the other.
|
|
|
1226
1257
|
(`handleFormSubmit`, `handleUpdateSelectedData`, `handleDeleteModal`, `handleExportReadPDF`)
|
|
1227
1258
|
target the panel instance. `handleRefresh()` refreshes both.
|
|
1228
1259
|
|
|
1260
|
+
### Adding a row in scroll mode
|
|
1261
|
+
|
|
1262
|
+
With `organise.paginationType: "scroll"` and `edit.listAddable`, an Add button holds the bottom of the screen instead of an empty row after the last record. It opens the new row at the top of the table.
|
|
1263
|
+
|
|
1264
|
+
### bareSettings.bareFormDrawer
|
|
1265
|
+
|
|
1266
|
+
Create and edit open in the same panel over the list, so the list keeps its scroll, page
|
|
1267
|
+
and filters, and reloads when the panel closes. Off by default. Placement comes from
|
|
1268
|
+
`viewSettings.readDrawer` when set; on phones the sheet takes 90% of the height. A click
|
|
1269
|
+
outside or Escape closes the form, and asks first once something has been edited.
|
|
1270
|
+
|
|
1229
1271
|
### viewSettings.readDrawer
|
|
1230
1272
|
|
|
1231
1273
|
| Name | Type | Default | Description |
|
|
1232
1274
|
| --------------- | -------------------------------------------------------------------- | -------------- | ----------------------------------------------------------------------------------- |
|
|
1233
1275
|
| `anchor` | `"bottom" \| "right" \| "responsive"` | `"responsive"` | `responsive` = right panel above the `sm` breakpoint (640px), bottom sheet below it |
|
|
1234
1276
|
| `maxWidth` | `"lg" \| "xl" \| "2xl" \| "3xl" \| "4xl" \| "5xl" \| "6xl" \| "7xl"` | `"3xl"` | Panel width for the right anchor |
|
|
1235
|
-
| `maxHeight` | `"50" \| "75" \| "100"`
|
|
1277
|
+
| `maxHeight` | `"50" \| "75" \| "90" \| "100"` | `"75"` | Sheet height for the bottom anchor |
|
|
1236
1278
|
| `title` | `string` | `title` prop | Panel header title |
|
|
1237
1279
|
| `desc` | `string` | — | Panel header description |
|
|
1238
1280
|
| `disableExpand` | `boolean` | `false` | Hides the panel's expand-to-page button, leaving the peek as the only read surface |
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { Transition } from "@headlessui/react";
|
|
2
|
+
import axios from "axios";
|
|
3
|
+
import React, { useState } from "react";
|
|
4
|
+
import { StorySection } from "../../storyUtils";
|
|
5
|
+
import { Loading } from "../../display/loading";
|
|
6
|
+
import { DataStructure, DevSettings } from "../dataTemplate/model";
|
|
7
|
+
import { DataTemplateWrapper } from "../dataWrapper";
|
|
8
|
+
import {
|
|
9
|
+
STORY_API_ENDPOINT,
|
|
10
|
+
STORY_NAMES,
|
|
11
|
+
storyWorkspace,
|
|
12
|
+
useStoryBackend,
|
|
13
|
+
} from "../dataWrapper/storyBackend.fixtures";
|
|
14
|
+
import {
|
|
15
|
+
FlexFilter,
|
|
16
|
+
FlexFilterRelatedSet,
|
|
17
|
+
renderFlexFilterConditions,
|
|
18
|
+
} from ".";
|
|
19
|
+
import { FlexFilterData } from "./model";
|
|
20
|
+
|
|
21
|
+
const meta = { title: "Data/Flex Filter/Related Rows" };
|
|
22
|
+
export default meta;
|
|
23
|
+
|
|
24
|
+
const USER_COLLECTION = "demo-rel-users";
|
|
25
|
+
const REFERRAL_COLLECTION = "demo-rel-referrals";
|
|
26
|
+
const REFERRAL_STATUSES = ["New", "Quoted", "Installed"];
|
|
27
|
+
|
|
28
|
+
const userSchema: DataStructure[] = [
|
|
29
|
+
{ id: "name", name: "Name", type: "string" },
|
|
30
|
+
{ id: "phone", name: "Phone", type: "contact" },
|
|
31
|
+
{
|
|
32
|
+
id: "status",
|
|
33
|
+
name: "Status",
|
|
34
|
+
type: "list",
|
|
35
|
+
listSelection: ["Active", "Inactive"],
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
const referralSchema: DataStructure[] = [
|
|
40
|
+
{ id: "referredName", name: "Referred Name", type: "string" },
|
|
41
|
+
{ id: "referrerPhone", name: "Referrer Phone", type: "contact" },
|
|
42
|
+
{ id: "referralDate", name: "Referral Date", type: "date" },
|
|
43
|
+
{
|
|
44
|
+
id: "status",
|
|
45
|
+
name: "Status",
|
|
46
|
+
type: "list",
|
|
47
|
+
listSelection: REFERRAL_STATUSES,
|
|
48
|
+
},
|
|
49
|
+
];
|
|
50
|
+
|
|
51
|
+
const related: FlexFilterRelatedSet = {
|
|
52
|
+
referrals: {
|
|
53
|
+
name: "Referrals",
|
|
54
|
+
collection: REFERRAL_COLLECTION,
|
|
55
|
+
localField: "phone",
|
|
56
|
+
foreignField: "referrerPhone",
|
|
57
|
+
structure: referralSchema,
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
// INFO: this month's dates, and last month's, so a date filter splits them
|
|
62
|
+
const handleThisMonth = (day: number) => {
|
|
63
|
+
const date = new Date();
|
|
64
|
+
date.setDate(Math.min(day, 28));
|
|
65
|
+
date.setHours(10, 0, 0, 0);
|
|
66
|
+
return date.toISOString();
|
|
67
|
+
};
|
|
68
|
+
const handleLastMonth = (day: number) => {
|
|
69
|
+
const date = new Date();
|
|
70
|
+
date.setDate(1);
|
|
71
|
+
date.setMonth(date.getMonth() - 1);
|
|
72
|
+
date.setDate(Math.min(day, 28));
|
|
73
|
+
date.setHours(10, 0, 0, 0);
|
|
74
|
+
return date.toISOString();
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const users = STORY_NAMES.slice(0, 8).map((name, index) => ({
|
|
78
|
+
_id: `user-${index + 1}`,
|
|
79
|
+
name,
|
|
80
|
+
phone: `6012${String(1000000 + index)}`,
|
|
81
|
+
status: index % 4 === 3 ? "Inactive" : "Active",
|
|
82
|
+
}));
|
|
83
|
+
|
|
84
|
+
// INFO: user n has n referrals this month and (7 - n) last month, so "more
|
|
85
|
+
// than 3 this month" and "more than 3 in total" pick different people
|
|
86
|
+
const referrals = users.flatMap((eachUser, index) => [
|
|
87
|
+
...Array.from({ length: index }, (_, row) => ({
|
|
88
|
+
_id: `ref-${index}-now-${row}`,
|
|
89
|
+
referredName: STORY_NAMES[(index + row + 3) % STORY_NAMES.length],
|
|
90
|
+
referrerPhone: eachUser.phone,
|
|
91
|
+
referralDate: handleThisMonth(row + 2),
|
|
92
|
+
status: REFERRAL_STATUSES[row % REFERRAL_STATUSES.length],
|
|
93
|
+
})),
|
|
94
|
+
...Array.from({ length: 7 - index }, (_, row) => ({
|
|
95
|
+
_id: `ref-${index}-last-${row}`,
|
|
96
|
+
referredName: STORY_NAMES[(index + row + 7) % STORY_NAMES.length],
|
|
97
|
+
referrerPhone: eachUser.phone,
|
|
98
|
+
referralDate: handleLastMonth(row + 2),
|
|
99
|
+
status: REFERRAL_STATUSES[(row + 1) % REFERRAL_STATUSES.length],
|
|
100
|
+
})),
|
|
101
|
+
]);
|
|
102
|
+
|
|
103
|
+
const extras = { related };
|
|
104
|
+
|
|
105
|
+
const devSettings: DevSettings = {
|
|
106
|
+
server: "mongoServer",
|
|
107
|
+
dbEndpoint: STORY_API_ENDPOINT,
|
|
108
|
+
imageEndpoint: "",
|
|
109
|
+
uploadEndpoint: "",
|
|
110
|
+
dbAPIToken: "",
|
|
111
|
+
dbUserToken: "",
|
|
112
|
+
schemaEndpoint: "",
|
|
113
|
+
bucketName: "",
|
|
114
|
+
organisationMembers: [],
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
export const RelatedRows = () => {
|
|
118
|
+
const mount = useStoryBackend({
|
|
119
|
+
collections: {
|
|
120
|
+
[USER_COLLECTION]: users,
|
|
121
|
+
[REFERRAL_COLLECTION]: referrals,
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
const [filters, setFilters] = useState<FlexFilterData[]>([
|
|
125
|
+
{ id: "init", columnId: "", operator: "=", value: undefined },
|
|
126
|
+
]);
|
|
127
|
+
const query = renderFlexFilterConditions(filters, userSchema, extras);
|
|
128
|
+
|
|
129
|
+
if (!mount) return <Loading />;
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<div className="flex flex-col space-y-12">
|
|
133
|
+
<StorySection
|
|
134
|
+
title="Referrers filtered on their referrals"
|
|
135
|
+
description="User 1 has no referrals this month and user 8 has seven; last month it is the other way round. Try Referrals › Referral Date between the 1st and the end of this month, with Referrals › Count Greater Than 3."
|
|
136
|
+
>
|
|
137
|
+
<DataTemplateWrapper
|
|
138
|
+
title="Referrers"
|
|
139
|
+
type="list"
|
|
140
|
+
collectionId={USER_COLLECTION}
|
|
141
|
+
schema={userSchema}
|
|
142
|
+
workspace={storyWorkspace()}
|
|
143
|
+
limit={20}
|
|
144
|
+
organise={{ breadcrumbType: "hidden" }}
|
|
145
|
+
bareSettings={{
|
|
146
|
+
bareNewDTW: true,
|
|
147
|
+
bareFlexFilters: true,
|
|
148
|
+
bareFlexFilterRelated: related,
|
|
149
|
+
}}
|
|
150
|
+
/>
|
|
151
|
+
</StorySection>
|
|
152
|
+
|
|
153
|
+
<StorySection
|
|
154
|
+
title="Standalone filter (notification audience)"
|
|
155
|
+
description="The same related datasheet on the bare FlexFilter, with the query it builds."
|
|
156
|
+
>
|
|
157
|
+
<div className="flex flex-col">
|
|
158
|
+
<div className="max-w-[700px]">
|
|
159
|
+
<FlexFilter
|
|
160
|
+
structure={userSchema}
|
|
161
|
+
extras={extras}
|
|
162
|
+
filters={filters}
|
|
163
|
+
onChange={setFilters}
|
|
164
|
+
devSettings={devSettings}
|
|
165
|
+
dbModule={axios}
|
|
166
|
+
variant="bare"
|
|
167
|
+
lib={{ Transition }}
|
|
168
|
+
/>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="mt-4 p-4 bg-gray-50 rounded-lg border">
|
|
171
|
+
<p className="text-sm font-semibold mb-2">
|
|
172
|
+
Generated Query Conditions ({query.length})
|
|
173
|
+
</p>
|
|
174
|
+
<pre className="text-xs whitespace-pre-wrap">
|
|
175
|
+
{JSON.stringify(query, null, 2)}
|
|
176
|
+
</pre>
|
|
177
|
+
</div>
|
|
178
|
+
</div>
|
|
179
|
+
</StorySection>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
};
|