@visns-studio/visns-components 6.1.7 → 6.2.0
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/README.md +161 -1
- package/package.json +1 -1
- package/src/components/Fetch.jsx +49 -19
- package/src/components/TableFilter.jsx +59 -45
- package/src/components/generic/GenericReport.jsx +835 -68
- package/src/components/generic/SectionGroupedReport.jsx +199 -226
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +81 -0
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +337 -0
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +483 -0
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +202 -0
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +157 -0
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +352 -0
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +154 -0
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +153 -0
- package/src/components/generic/reportSemantics.js +971 -0
- package/src/components/generic/useSemanticModel.js +99 -0
- package/src/components/styles/ReportSemantic.module.scss +444 -0
- package/src/index.js +4 -0
package/README.md
CHANGED
|
@@ -3300,13 +3300,173 @@ A powerful and enhanced report building component that provides an intuitive wiz
|
|
|
3300
3300
|
columnUrl: '/ajax/reportBuilder/getTableColumns',
|
|
3301
3301
|
reportsUrl: '/ajax/reportBuilder/reports',
|
|
3302
3302
|
executeUrl: '/ajax/reportBuilder/execute',
|
|
3303
|
-
|
|
3303
|
+
detectedJoinsUrl: '/ajax/reportBuilder/getSuggestedJoins',
|
|
3304
|
+
exportUrl: '/ajax/reportBuilder/export',
|
|
3305
|
+
semanticModelUrl: '/ajax/reportBuilder/semanticModel',
|
|
3304
3306
|
}}
|
|
3305
3307
|
userProfile={userProfile}
|
|
3306
3308
|
businessTemplates={businessTemplates} // Optional pre-built templates
|
|
3307
3309
|
/>
|
|
3308
3310
|
```
|
|
3309
3311
|
|
|
3312
|
+
**Semantic Mode (report definition v2)**
|
|
3313
|
+
|
|
3314
|
+
On mount the component POSTs to `setting.semanticModelUrl` (default
|
|
3315
|
+
`/ajax/reportBuilder/semanticModel`). If it responds with
|
|
3316
|
+
`{success: true, data: {entities: {...}}}` the wizard switches to **semantic
|
|
3317
|
+
mode**, where the user picks business entities, labelled fields and labelled
|
|
3318
|
+
relations — no table or column names are ever shown. If the endpoint 404s or
|
|
3319
|
+
errors, the wizard silently falls back to **legacy mode** (the existing
|
|
3320
|
+
table/column/join behaviour), so this is fully backwards compatible.
|
|
3321
|
+
|
|
3322
|
+
Semantic model response shape:
|
|
3323
|
+
|
|
3324
|
+
```json
|
|
3325
|
+
{
|
|
3326
|
+
"success": true,
|
|
3327
|
+
"data": {
|
|
3328
|
+
"entities": {
|
|
3329
|
+
"clients": {
|
|
3330
|
+
"label": "Clients",
|
|
3331
|
+
"plural": "Clients",
|
|
3332
|
+
"description": "People the practice advises",
|
|
3333
|
+
"fields": {
|
|
3334
|
+
"firstname": { "label": "First name", "type": "text" },
|
|
3335
|
+
"fee_amount": {
|
|
3336
|
+
"label": "Fee amount",
|
|
3337
|
+
"type": "money",
|
|
3338
|
+
"summable": true
|
|
3339
|
+
},
|
|
3340
|
+
"fds_due_date": { "label": "FDS due date", "type": "date" },
|
|
3341
|
+
"status": {
|
|
3342
|
+
"label": "Status",
|
|
3343
|
+
"type": "enum",
|
|
3344
|
+
"values": { "1": "Active", "0": "Inactive" }
|
|
3345
|
+
}
|
|
3346
|
+
},
|
|
3347
|
+
"relations": {
|
|
3348
|
+
"adviser": {
|
|
3349
|
+
"label": "Their adviser",
|
|
3350
|
+
"entity": "users",
|
|
3351
|
+
"cardinality": "one"
|
|
3352
|
+
},
|
|
3353
|
+
"notes": {
|
|
3354
|
+
"label": "Their notes",
|
|
3355
|
+
"entity": "client_notes",
|
|
3356
|
+
"cardinality": "many"
|
|
3357
|
+
}
|
|
3358
|
+
}
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
```
|
|
3364
|
+
|
|
3365
|
+
Field types are `text | number | money | percent | date | datetime | boolean | enum`.
|
|
3366
|
+
Field ids are opaque handles — the server maps them to storage, so the client
|
|
3367
|
+
never needs to know the underlying table or column.
|
|
3368
|
+
|
|
3369
|
+
The wizard builds a **report definition v2** document, saves it in `detail`, and
|
|
3370
|
+
POSTs it when running or exporting the report:
|
|
3371
|
+
|
|
3372
|
+
```json
|
|
3373
|
+
{
|
|
3374
|
+
"schema_version": 2,
|
|
3375
|
+
"entity": "clients",
|
|
3376
|
+
"fields": [
|
|
3377
|
+
{ "field": "firstname" },
|
|
3378
|
+
{ "field": "adviser.name" },
|
|
3379
|
+
{ "agg": "sum", "field": "fee_amount", "label": "Total fees" }
|
|
3380
|
+
],
|
|
3381
|
+
"filters": {
|
|
3382
|
+
"op": "and",
|
|
3383
|
+
"items": [
|
|
3384
|
+
{ "field": "status", "operator": "equals", "value": "1" },
|
|
3385
|
+
{
|
|
3386
|
+
"op": "or",
|
|
3387
|
+
"items": [
|
|
3388
|
+
{ "field": "home_email", "operator": "not_empty" },
|
|
3389
|
+
{ "field": "work_email", "operator": "not_empty" }
|
|
3390
|
+
]
|
|
3391
|
+
},
|
|
3392
|
+
{
|
|
3393
|
+
"field": "fds_due_date",
|
|
3394
|
+
"operator": "between",
|
|
3395
|
+
"param": "due_range"
|
|
3396
|
+
}
|
|
3397
|
+
]
|
|
3398
|
+
},
|
|
3399
|
+
"parameters": [
|
|
3400
|
+
{
|
|
3401
|
+
"id": "due_range",
|
|
3402
|
+
"label": "Due date range",
|
|
3403
|
+
"type": "date_range",
|
|
3404
|
+
"required": true
|
|
3405
|
+
}
|
|
3406
|
+
],
|
|
3407
|
+
"groupBy": ["adviser.name"],
|
|
3408
|
+
"sort": [{ "field": "surname", "dir": "asc" }]
|
|
3409
|
+
}
|
|
3410
|
+
```
|
|
3411
|
+
|
|
3412
|
+
- **Relation dot-paths**: a `relation.field` path (e.g. `adviser.name`) reaches
|
|
3413
|
+
a field through a declared relation. Only relations declared in the semantic
|
|
3414
|
+
model can be traversed.
|
|
3415
|
+
- **Aggregates**: `sum | count | avg | min | max`. `sum` and `avg` are offered
|
|
3416
|
+
only on fields marked `summable: true` or typed `number`, `money` or
|
|
3417
|
+
`percent`; `count` is available on anything.
|
|
3418
|
+
- **Runtime parameters**: a filter carrying `param` instead of `value` is a
|
|
3419
|
+
runtime parameter — the run screen prompts for it using the matching entry
|
|
3420
|
+
in `parameters` before executing.
|
|
3421
|
+
|
|
3422
|
+
**Operators by field type:**
|
|
3423
|
+
|
|
3424
|
+
| Field type | Operators |
|
|
3425
|
+
| ----------------------- | --------------------------------------------------------------------------- |
|
|
3426
|
+
| `text` | `equals`, `not_equals`, `contains`, `not_contains`, `is_empty`, `not_empty` |
|
|
3427
|
+
| `number`/`money`/`percent` | `equals`, `not_equals`, `gt`, `gte`, `lt`, `lte`, `between`, `is_empty`, `not_empty` |
|
|
3428
|
+
| `date`/`datetime` | `equals`, `before`, `after`, `between`, `is_empty`, `not_empty` |
|
|
3429
|
+
| `boolean` | `is_true`, `is_false` |
|
|
3430
|
+
| `enum` | `equals`, `not_equals`, `in`, `not_in` |
|
|
3431
|
+
|
|
3432
|
+
**Execute contract:**
|
|
3433
|
+
|
|
3434
|
+
- `POST executeUrl` with `{definition, parameters, limit, offset}` →
|
|
3435
|
+
`{success, data: [rows], total}`. Each row is keyed by the field path or the
|
|
3436
|
+
aggregate label exactly as given in `fields` — e.g. `"firstname"`,
|
|
3437
|
+
`"adviser.name"`, `"Total fees"`.
|
|
3438
|
+
- `POST exportUrl` with `{definition, parameters, format}` → a file blob.
|
|
3439
|
+
|
|
3440
|
+
Save/load endpoints are unchanged; `detail` simply holds the v2 document. A
|
|
3441
|
+
legacy v1 document (one that has `mainTable`) keeps the current legacy
|
|
3442
|
+
behaviour.
|
|
3443
|
+
|
|
3444
|
+
The pure helpers behind all of this live in
|
|
3445
|
+
`src/components/generic/reportSemantics.js` (model lookup, operator menus,
|
|
3446
|
+
definition serialisation) with the endpoint probe in
|
|
3447
|
+
`src/components/generic/useSemanticModel.js`. They are covered by:
|
|
3448
|
+
|
|
3449
|
+
```bash
|
|
3450
|
+
node --test tests/reportSemantics.test.mjs
|
|
3451
|
+
```
|
|
3452
|
+
|
|
3453
|
+
**Embeddable grouped report viewers**
|
|
3454
|
+
|
|
3455
|
+
`GroupedReportRenderer` and `SectionGroupedReport` are exported so consuming apps
|
|
3456
|
+
can embed a read-only grouped report view without the wizard. Columns accept an
|
|
3457
|
+
explicit `key` (the exact row key returned by the execute contract) and a
|
|
3458
|
+
`header` label:
|
|
3459
|
+
|
|
3460
|
+
```jsx
|
|
3461
|
+
import { GroupedReportRenderer } from '@visns-studio/visns-components';
|
|
3462
|
+
|
|
3463
|
+
<GroupedReportRenderer
|
|
3464
|
+
data={{ grouped: true, groups, totalGroups, totalRecords }}
|
|
3465
|
+
config={{ type: 'sections', groupDisplayName: 'Adviser' }}
|
|
3466
|
+
columns={[{ key: 'adviser.name', header: 'Adviser' }]}
|
|
3467
|
+
/>;
|
|
3468
|
+
```
|
|
3469
|
+
|
|
3310
3470
|
**Major Enhancements:**
|
|
3311
3471
|
|
|
3312
3472
|
- **6-Step Wizard Interface**: Guided workflow with visual progress indicators
|
package/package.json
CHANGED
|
@@ -91,7 +91,7 @@
|
|
|
91
91
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
92
92
|
},
|
|
93
93
|
"name": "@visns-studio/visns-components",
|
|
94
|
-
"version": "6.
|
|
94
|
+
"version": "6.2.0",
|
|
95
95
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
96
96
|
"main": "src/index.js",
|
|
97
97
|
"files": [
|
package/src/components/Fetch.jsx
CHANGED
|
@@ -124,6 +124,41 @@ const invalidateRelatedCaches = (url, method) => {
|
|
|
124
124
|
}
|
|
125
125
|
};
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* Turn an API error bag into displayable HTML, whatever shape it arrived in.
|
|
129
|
+
*
|
|
130
|
+
* Laravel validation sends `{field: ["msg", ...]}`; the report-semantics
|
|
131
|
+
* endpoints send `[{path, message}, ...]` (see report-semantics.md §6). A bare
|
|
132
|
+
* list of strings is accepted too. Anything unreadable yields '' so the caller
|
|
133
|
+
* can fall back to `data.message` — never a thrown TypeError, which used to
|
|
134
|
+
* leave the request promise permanently unsettled.
|
|
135
|
+
*/
|
|
136
|
+
const flattenErrorBag = (errors) => {
|
|
137
|
+
const lines = [];
|
|
138
|
+
|
|
139
|
+
const collect = (entry) => {
|
|
140
|
+
if (entry === null || entry === undefined) return;
|
|
141
|
+
if (Array.isArray(entry)) {
|
|
142
|
+
entry.forEach(collect);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
if (typeof entry === 'object') {
|
|
146
|
+
// `{path, message}` from the semantic compiler.
|
|
147
|
+
if (typeof entry.message === 'string') {
|
|
148
|
+
lines.push(entry.message);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
Object.values(entry).forEach(collect);
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
lines.push(String(entry));
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
collect(errors);
|
|
158
|
+
|
|
159
|
+
return lines.filter(Boolean).map((line) => `${line}<br />`).join('');
|
|
160
|
+
};
|
|
161
|
+
|
|
127
162
|
// Original CustomFetch logic extracted for reuse
|
|
128
163
|
const originalCustomFetch = (
|
|
129
164
|
url,
|
|
@@ -275,13 +310,12 @@ const originalCustomFetch = (
|
|
|
275
310
|
const { data } = error.response;
|
|
276
311
|
|
|
277
312
|
if (data.errors) {
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
});
|
|
313
|
+
errorMessage = flattenErrorBag(data.errors);
|
|
314
|
+
// A bag we could not read (or an empty one) must still
|
|
315
|
+
// surface something the user can act on.
|
|
316
|
+
if (!errorMessage && data.message) {
|
|
317
|
+
errorMessage = data.message;
|
|
318
|
+
}
|
|
285
319
|
} else if (data.message) {
|
|
286
320
|
if (data.message === 'CSRF token mismatch.') {
|
|
287
321
|
window.location.replace('/login');
|
|
@@ -345,12 +379,10 @@ const CustomFetch = (
|
|
|
345
379
|
if (error.response && error.response.data) {
|
|
346
380
|
const { data } = error.response;
|
|
347
381
|
if (data.errors) {
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
});
|
|
353
|
-
});
|
|
382
|
+
errorMessage =
|
|
383
|
+
flattenErrorBag(data.errors) ||
|
|
384
|
+
data.message ||
|
|
385
|
+
'';
|
|
354
386
|
} else if (data.message) {
|
|
355
387
|
errorMessage = data.message;
|
|
356
388
|
}
|
|
@@ -364,12 +396,10 @@ const CustomFetch = (
|
|
|
364
396
|
if (error.response && error.response.data) {
|
|
365
397
|
const { data } = error.response;
|
|
366
398
|
if (data.errors) {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
});
|
|
372
|
-
});
|
|
399
|
+
errorMessage =
|
|
400
|
+
flattenErrorBag(data.errors) ||
|
|
401
|
+
data.message ||
|
|
402
|
+
'';
|
|
373
403
|
} else if (data.message) {
|
|
374
404
|
if (data.message === 'CSRF token mismatch.') {
|
|
375
405
|
window.location.replace('/login');
|
|
@@ -390,6 +390,7 @@ function TableFilter({
|
|
|
390
390
|
|
|
391
391
|
// Calculate the updated settings
|
|
392
392
|
let updatedSettings = null;
|
|
393
|
+
let applySettingsUpdate = null;
|
|
393
394
|
|
|
394
395
|
if (setSettings) {
|
|
395
396
|
// For child items, non-parent items, or parent items with a value property
|
|
@@ -406,66 +407,73 @@ function TableFilter({
|
|
|
406
407
|
const filterId = id;
|
|
407
408
|
const filterValue = value || '';
|
|
408
409
|
|
|
409
|
-
//
|
|
410
|
-
//
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
410
|
+
// Merge this filter into a settings object, supporting both
|
|
411
|
+
// ajaxSetting.where and a top-level where. Written as a pure
|
|
412
|
+
// function of the base settings so it can run either against
|
|
413
|
+
// the `settings` prop or inside a functional state update —
|
|
414
|
+
// a state setter carries no readable current value.
|
|
415
|
+
const mergeFilterIntoSettings = (base) => {
|
|
416
|
+
let updated = base ? { ...base } : {};
|
|
417
|
+
|
|
418
|
+
if (updated.ajaxSetting) {
|
|
419
|
+
// First, remove any existing filters that might conflict
|
|
420
|
+
let _where = updated.ajaxSetting.where
|
|
421
|
+
? [...updated.ajaxSetting.where].filter((item) => {
|
|
420
422
|
// Keep items that don't match this filter ID
|
|
421
423
|
return item.id !== filterId;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
: [];
|
|
424
|
+
})
|
|
425
|
+
: [];
|
|
425
426
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
427
|
+
// Add the new filter only if both filterId and filterValue have values
|
|
428
|
+
if (filterId && filterValue !== '') {
|
|
429
|
+
_where.push({ id: filterId, value: filterValue });
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return {
|
|
433
|
+
...updated,
|
|
434
|
+
ajaxSetting: {
|
|
435
|
+
...updated.ajaxSetting,
|
|
436
|
+
where: _where,
|
|
437
|
+
},
|
|
438
|
+
};
|
|
429
439
|
}
|
|
430
440
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
...updatedSettings.ajaxSetting,
|
|
435
|
-
where: _where,
|
|
436
|
-
},
|
|
437
|
-
};
|
|
438
|
-
} else if (updatedSettings.where) {
|
|
439
|
-
// Handle the case where we're using 'where' directly instead of ajaxSetting.where
|
|
440
|
-
let w = updatedSettings.where
|
|
441
|
-
? [...updatedSettings.where]
|
|
442
|
-
: [];
|
|
441
|
+
if (updated.where) {
|
|
442
|
+
// Handle the case where we're using 'where' directly instead of ajaxSetting.where
|
|
443
|
+
let w = [...updated.where];
|
|
443
444
|
|
|
444
|
-
|
|
445
|
-
|
|
445
|
+
// Remove any existing filters that might conflict
|
|
446
|
+
w = w.filter((item) => item.id !== filterId);
|
|
446
447
|
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
448
|
+
// Add the new filter only if both filterId and filterValue have values
|
|
449
|
+
if (filterId && filterValue !== '') {
|
|
450
|
+
w.push({ id: filterId, value: filterValue });
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
return { ...updated, where: w };
|
|
450
454
|
}
|
|
451
455
|
|
|
452
|
-
updatedSettings = { ...updatedSettings, where: w };
|
|
453
|
-
} else {
|
|
454
456
|
// If neither ajaxSetting.where nor where exists, create a new ajaxSetting
|
|
455
457
|
// Only apply the filter if both filterId and filterValue have values
|
|
456
458
|
if (filterId && filterValue !== '') {
|
|
457
|
-
|
|
458
|
-
...
|
|
459
|
+
return {
|
|
460
|
+
...updated,
|
|
459
461
|
ajaxSetting: {
|
|
460
|
-
...(settings.ajaxSetting || {}),
|
|
461
462
|
where: [{ id: filterId, value: filterValue }],
|
|
462
463
|
},
|
|
463
464
|
};
|
|
464
|
-
} else {
|
|
465
|
-
// If either filterId or filterValue is empty, just copy the existing settings
|
|
466
|
-
updatedSettings = { ...settings };
|
|
467
465
|
}
|
|
468
|
-
|
|
466
|
+
|
|
467
|
+
return updated;
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
applySettingsUpdate = mergeFilterIntoSettings;
|
|
471
|
+
// With a settings prop we can compute the result now (also
|
|
472
|
+
// feeds onFilterChange); without one the update happens as a
|
|
473
|
+
// functional setState below.
|
|
474
|
+
updatedSettings = settings
|
|
475
|
+
? mergeFilterIntoSettings(settings)
|
|
476
|
+
: null;
|
|
469
477
|
}
|
|
470
478
|
}
|
|
471
479
|
|
|
@@ -477,8 +485,14 @@ function TableFilter({
|
|
|
477
485
|
setFilters(updatedFilters);
|
|
478
486
|
|
|
479
487
|
// Update the settings if needed
|
|
480
|
-
if (
|
|
481
|
-
|
|
488
|
+
if (setSettings && applySettingsUpdate) {
|
|
489
|
+
if (updatedSettings) {
|
|
490
|
+
setSettings(updatedSettings);
|
|
491
|
+
} else {
|
|
492
|
+
// No settings prop to read from — merge against the
|
|
493
|
+
// live state via a functional update instead.
|
|
494
|
+
setSettings((prev) => applySettingsUpdate(prev));
|
|
495
|
+
}
|
|
482
496
|
}
|
|
483
497
|
}
|
|
484
498
|
|