@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 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
- suggestedJoinsUrl: '/ajax/reportBuilder/getSuggestedJoins',
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.1.7",
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": [
@@ -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
- const { errors } = data;
279
-
280
- Object.values(errors).forEach((errorArray) => {
281
- errorArray.forEach((errorMsg) => {
282
- errorMessage += `${errorMsg}<br />`;
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
- const { errors } = data;
349
- Object.values(errors).forEach((errorArray) => {
350
- errorArray.forEach((errorMsg) => {
351
- errorMessage += `${errorMsg}<br />`;
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
- const { errors } = data;
368
- Object.values(errors).forEach((errorArray) => {
369
- errorArray.forEach((errorMsg) => {
370
- errorMessage += `${errorMsg}<br />`;
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
- // Start with the current settings
410
- // Check if _currentValue exists (React state setter) or use the object directly
411
- updatedSettings = setSettings._currentValue
412
- ? { ...setSettings._currentValue }
413
- : { ...setSettings };
414
-
415
- if (updatedSettings.ajaxSetting) {
416
- // First, remove any existing filters that might conflict
417
- let _where = updatedSettings.ajaxSetting.where
418
- ? [...updatedSettings.ajaxSetting.where].filter(
419
- (item) => {
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
- // Add the new filter only if both filterId and filterValue have values
427
- if (filterId && filterValue !== '') {
428
- _where.push({ id: filterId, value: filterValue });
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
- updatedSettings = {
432
- ...updatedSettings,
433
- ajaxSetting: {
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
- // Remove any existing filters that might conflict
445
- w = w.filter((item) => item.id !== filterId);
445
+ // Remove any existing filters that might conflict
446
+ w = w.filter((item) => item.id !== filterId);
446
447
 
447
- // Add the new filter only if both filterId and filterValue have values
448
- if (filterId && filterValue !== '') {
449
- w.push({ id: filterId, value: filterValue });
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
- updatedSettings = {
458
- ...settings,
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 (updatedSettings && setSettings) {
481
- setSettings(updatedSettings);
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