@visns-studio/visns-components 5.26.0 → 5.26.2

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/package.json CHANGED
@@ -94,7 +94,7 @@
94
94
  "react-dom": "^17.0.0 || ^18.0.0"
95
95
  },
96
96
  "name": "@visns-studio/visns-components",
97
- "version": "5.26.0",
97
+ "version": "5.26.2",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -242,6 +242,7 @@ const DataGrid = forwardRef(
242
242
  form,
243
243
  gridHeight,
244
244
  hoverColor,
245
+ legend,
245
246
  mapbox,
246
247
  pageData,
247
248
  pageSetting,
@@ -473,6 +474,36 @@ const DataGrid = forwardRef(
473
474
  setColumnsMetadata(res.metadata);
474
475
  }
475
476
 
477
+ // Opt-in (ajaxSetting.suppressRepeated {key, columns}):
478
+ // when consecutive rows share the same non-empty `key`
479
+ // value, blank the listed fields on every row after the
480
+ // first, so the first row of the run reads as the group
481
+ // row and the rest show only what differs. Rows must
482
+ // already be sorted so related rows are adjacent.
483
+ if (
484
+ ajaxSetting?.suppressRepeated?.key &&
485
+ Array.isArray(ajaxSetting.suppressRepeated.columns) &&
486
+ Array.isArray(res.data)
487
+ ) {
488
+ const { key, columns: repeatedFields } =
489
+ ajaxSetting.suppressRepeated;
490
+
491
+ for (let i = 1; i < res.data.length; i++) {
492
+ const current = res.data[i]?.[key];
493
+ const previous = res.data[i - 1]?.[key];
494
+
495
+ if (
496
+ current != null &&
497
+ current !== '' &&
498
+ current === previous
499
+ ) {
500
+ repeatedFields.forEach((field) => {
501
+ res.data[i][field] = null;
502
+ });
503
+ }
504
+ }
505
+ }
506
+
476
507
  // Group rows together while preserving the backend's sortBy order:
477
508
  // first appearance of each group key determines group order, so when
478
509
  // the backend sorts by e.g. pickling_completed_at desc, groups appear
@@ -524,6 +555,103 @@ const DataGrid = forwardRef(
524
555
  }
525
556
  dataRef.current = res.data;
526
557
 
558
+ // Opt-in grouped display (ajaxSetting.treeGroupBy {key,
559
+ // column, parentFields, countLabel}): consecutive rows
560
+ // sharing a non-empty `key` value get a synthetic summary
561
+ // parent row inserted above them, always visible — no
562
+ // expand/collapse. `parentFields` are lifted from the
563
+ // first row onto the parent and blanked on the children,
564
+ // so shared details display once; children get an "↳"
565
+ // prefix in the display column. Single rows stay plain.
566
+ // Runs after dataRef is set so selection/group helpers
567
+ // keep operating on the real rows only.
568
+ if (
569
+ ajaxSetting?.treeGroupBy?.key &&
570
+ Array.isArray(res.data)
571
+ ) {
572
+ const {
573
+ key: treeKey,
574
+ column: treeDisplayColumn,
575
+ parentFields = [],
576
+ countLabel,
577
+ } = ajaxSetting.treeGroupBy;
578
+ const rows = res.data;
579
+ const grouped = [];
580
+ let i = 0;
581
+
582
+ while (i < rows.length) {
583
+ const row = rows[i];
584
+ const value = row?.[treeKey];
585
+ let j = i + 1;
586
+
587
+ if (value != null && value !== '') {
588
+ while (
589
+ j < rows.length &&
590
+ rows[j]?.[treeKey] === value
591
+ ) {
592
+ j++;
593
+ }
594
+ }
595
+
596
+ if (j - i > 1) {
597
+ const children = rows.slice(i, j);
598
+ const parent = {
599
+ __treeParent: true,
600
+ id: `tree-group-${value}`,
601
+ [treeKey]: value,
602
+ };
603
+
604
+ // Show the group value in the display column
605
+ if (treeDisplayColumn) {
606
+ parent[treeDisplayColumn] = value;
607
+ }
608
+
609
+ // Lift shared fields onto the parent, blank
610
+ // them on every child
611
+ parentFields.forEach((field) => {
612
+ parent[field] = children[0]?.[field];
613
+ children.forEach((child) => {
614
+ child[field] = null;
615
+ });
616
+ });
617
+
618
+ // Mark children for styling (getRowStyle) and
619
+ // prefix their display value so nesting reads
620
+ children.forEach((child) => {
621
+ child.__treeChild = true;
622
+
623
+ if (
624
+ treeDisplayColumn &&
625
+ child[treeDisplayColumn] != null &&
626
+ String(
627
+ child[treeDisplayColumn]
628
+ ).indexOf('↳') !== 0
629
+ ) {
630
+ child[treeDisplayColumn] =
631
+ '↳ ' + child[treeDisplayColumn];
632
+ }
633
+ });
634
+
635
+ // Optional member count, e.g. "3 Headers"
636
+ if (countLabel?.field && countLabel?.template) {
637
+ parent[countLabel.field] =
638
+ countLabel.template.replace(
639
+ '{count}',
640
+ String(children.length)
641
+ );
642
+ }
643
+
644
+ grouped.push(parent, ...children);
645
+ } else {
646
+ grouped.push(row);
647
+ }
648
+
649
+ i = j;
650
+ }
651
+
652
+ res.data = grouped;
653
+ }
654
+
527
655
  // Force grid to recalculate row heights after data changes
528
656
  // This helps with multi-line content timing without interfering with core mechanisms
529
657
  setTimeout(() => {
@@ -687,6 +815,19 @@ const DataGrid = forwardRef(
687
815
  const getRowStyle = useCallback(
688
816
  (rowProps) => {
689
817
  const { data } = rowProps;
818
+ const style = {};
819
+
820
+ // Tree mode: mark the summary parent and its nested
821
+ // children with a left accent bar so the block reads as
822
+ // one unit and children are visibly subordinate
823
+ if (ajaxSetting?.treeGroupBy?.key) {
824
+ if (data?.__treeParent) {
825
+ style.borderLeft = '4px solid #2e7dbd';
826
+ style.fontWeight = 600;
827
+ } else if (data?.__treeChild) {
828
+ style.borderLeft = '4px solid #93c5fd';
829
+ }
830
+ }
690
831
 
691
832
  if (ajaxSetting?.rowColours?.length > 0) {
692
833
  const colorConfig = ajaxSetting.rowColours.find(
@@ -694,13 +835,11 @@ const DataGrid = forwardRef(
694
835
  );
695
836
 
696
837
  if (colorConfig) {
697
- return {
698
- backgroundColor: colorConfig.colour,
699
- };
838
+ style.backgroundColor = colorConfig.colour;
700
839
  }
701
840
  }
702
841
 
703
- return null;
842
+ return Object.keys(style).length > 0 ? style : null;
704
843
  },
705
844
  [ajaxSetting]
706
845
  );
@@ -821,12 +960,14 @@ const DataGrid = forwardRef(
821
960
  const cleanSelection = {};
822
961
  Object.keys(param.selected).forEach((key) => {
823
962
  const item = param.selected[key];
824
- // Only keep items that have valid IDs and are not group objects
963
+ // Only keep items that have valid IDs and are not
964
+ // group objects or synthetic tree parent rows
825
965
  if (
826
966
  key !== 'undefined' &&
827
967
  key !== 'null' &&
828
968
  item &&
829
969
  !item.__group &&
970
+ !item.__treeParent &&
830
971
  item.id !== undefined
831
972
  ) {
832
973
  cleanSelection[key] = item;
@@ -3373,7 +3514,43 @@ const DataGrid = forwardRef(
3373
3514
  }
3374
3515
  };
3375
3516
 
3376
- const newColumns = columns.map(renderColumn);
3517
+ // Interactive cells (inline dropdowns, timers, stage
3518
+ // toggles) must not render on synthetic tree parent
3519
+ // rows — they'd write against the fake id. Display-only
3520
+ // renders (text, relation, gallery, stageCounter) are
3521
+ // safe and show the lifted shared values.
3522
+ const TREE_PARENT_BLOCKED_TYPES = [
3523
+ 'dropdown',
3524
+ 'multiDropdown',
3525
+ 'timer',
3526
+ 'stage',
3527
+ 'input',
3528
+ ];
3529
+ const guardTreeParentRender = (gridColumn, columnDef) => {
3530
+ if (
3531
+ !ajaxSetting?.treeGroupBy?.key ||
3532
+ !gridColumn?.render ||
3533
+ !TREE_PARENT_BLOCKED_TYPES.includes(
3534
+ columnDef?.type
3535
+ )
3536
+ ) {
3537
+ return gridColumn;
3538
+ }
3539
+
3540
+ const originalRender = gridColumn.render;
3541
+
3542
+ return {
3543
+ ...gridColumn,
3544
+ render: (arg) =>
3545
+ arg?.data?.__treeParent
3546
+ ? null
3547
+ : originalRender(arg),
3548
+ };
3549
+ };
3550
+
3551
+ const newColumns = columns.map((column) =>
3552
+ guardTreeParentRender(renderColumn(column), column)
3553
+ );
3377
3554
 
3378
3555
  // Count settings that could render in the trailing Action
3379
3556
  // column: role-permitted AND not claimed by an 'action' column.
@@ -3417,6 +3594,12 @@ const DataGrid = forwardRef(
3417
3594
  ? style.text_vertical_align
3418
3595
  : 'center',
3419
3596
  render: ({ data }) => {
3597
+ // Synthetic tree parent rows are summaries —
3598
+ // no row actions
3599
+ if (data?.__treeParent) {
3600
+ return null;
3601
+ }
3602
+
3420
3603
  return (
3421
3604
  <div className={styles.tdactions}>
3422
3605
  {memoizedSettings
@@ -3774,6 +3957,49 @@ const DataGrid = forwardRef(
3774
3957
  </div>
3775
3958
  )}
3776
3959
 
3960
+ {/* Opt-in colour legend (config key `legend`: array of
3961
+ {colour, label}); entries without a colour render as
3962
+ plain lead-in text */}
3963
+ {Array.isArray(legend) && legend.length > 0 && (
3964
+ <div
3965
+ style={{
3966
+ display: 'flex',
3967
+ flexWrap: 'wrap',
3968
+ alignItems: 'center',
3969
+ gap: '14px',
3970
+ padding: '6px 4px 8px',
3971
+ fontSize: '0.8rem',
3972
+ color: '#555',
3973
+ }}
3974
+ >
3975
+ {legend.map((item, index) => (
3976
+ <span
3977
+ key={index}
3978
+ style={{
3979
+ display: 'inline-flex',
3980
+ alignItems: 'center',
3981
+ gap: '6px',
3982
+ }}
3983
+ >
3984
+ {(item.colour || item.color) && (
3985
+ <span
3986
+ style={{
3987
+ width: '14px',
3988
+ height: '14px',
3989
+ borderRadius: '3px',
3990
+ backgroundColor:
3991
+ item.colour || item.color,
3992
+ display: 'inline-block',
3993
+ flexShrink: 0,
3994
+ }}
3995
+ />
3996
+ )}
3997
+ {item.label}
3998
+ </span>
3999
+ ))}
4000
+ </div>
4001
+ )}
4002
+
3777
4003
  {/* DataGrid container with its own border */}
3778
4004
  <div className={styles.dataGridContainer}>
3779
4005
 
@@ -9,7 +9,7 @@ import moment from 'moment';
9
9
  import parse from 'html-react-parser';
10
10
  import _ from 'lodash';
11
11
  import ReactDataGrid from '@visns-studio/visns-datagrid-enterprise';
12
- import { X } from 'lucide-react';
12
+ import { Info, X } from 'lucide-react';
13
13
  import imageCompression from 'browser-image-compression';
14
14
  import { confirmDialog } from './utils/ConfirmDialog';
15
15
 
@@ -2686,11 +2686,11 @@ function Form({
2686
2686
  <div
2687
2687
  className={`${styles.formItem} ${styles.fwItem}`}
2688
2688
  style={{
2689
- width: formSettings?.save?.return?.width
2690
- ? `min(${parseInt(
2691
- formSettings.save.return.width
2692
- )}px, 100vw - 40px)`
2693
- : '100%',
2689
+ // Never exceed the parent panel (sizing
2690
+ // against 100vw bled past narrower panels);
2691
+ // the grid keeps its configured px width
2692
+ // inside and scrolls horizontally here
2693
+ width: '100%',
2694
2694
  maxWidth: formSettings?.save?.return?.width
2695
2695
  ? `${parseInt(
2696
2696
  formSettings.save.return.width
@@ -3376,82 +3376,128 @@ function Form({
3376
3376
  </div>
3377
3377
  );
3378
3378
  } else if (formType === 'update') {
3379
+ const saveAnotherDisableWhen =
3380
+ formSettings.update?.saveAnother
3381
+ ?.disableWhen;
3382
+ const saveAnotherDisabled =
3383
+ saveAnotherDisableWhen
3384
+ ? evaluateDisableWhen(
3385
+ saveAnotherDisableWhen,
3386
+ formData
3387
+ )
3388
+ : false;
3389
+
3379
3390
  return (
3380
- <div
3381
- className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
3382
- >
3383
- <button
3384
- className={styles.btn}
3385
- onClick={handleSubmit}
3386
- data-type="save"
3391
+ <>
3392
+ <div
3393
+ className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
3387
3394
  >
3388
- Save & Close
3389
- </button>
3390
- {formSettings.update?.hasOwnProperty(
3391
- 'saveAnother'
3392
- ) &&
3393
- (() => {
3394
- const disableWhen =
3395
- formSettings.update
3396
- .saveAnother
3397
- ?.disableWhen;
3398
- const isDisabled =
3399
- disableWhen
3400
- ? evaluateDisableWhen(
3401
- disableWhen,
3402
- formData
3403
- )
3404
- : false;
3405
-
3406
- return (
3407
- <button
3408
- className={
3409
- styles.btn
3410
- }
3411
- onClick={
3412
- handleSubmit
3413
- }
3414
- data-type="saveAnother"
3415
- disabled={
3416
- isDisabled
3417
- }
3418
- title={
3419
- isDisabled
3420
- ? disableWhen?.message ||
3421
- ''
3422
- : undefined
3423
- }
3424
- style={
3425
- isDisabled
3426
- ? {
3427
- opacity: 0.5,
3428
- cursor: 'not-allowed',
3429
- }
3430
- : undefined
3431
- }
3432
- >
3433
- {formSettings
3434
- .update
3435
- .saveAnother
3436
- ?.label ||
3437
- 'Save & Create Another'}
3438
- </button>
3439
- );
3440
- })()}
3441
- {formSettings.update?.hasOwnProperty(
3442
- 'saveExit'
3443
- ) && (
3444
3395
  <button
3445
3396
  className={styles.btn}
3446
3397
  onClick={handleSubmit}
3447
- data-type="saveExit"
3398
+ data-type="save"
3448
3399
  >
3449
- {formSettings.update
3450
- .saveExit?.label ||
3451
- 'Save & Exit'}
3400
+ Save & Close
3452
3401
  </button>
3402
+ {formSettings.update?.hasOwnProperty(
3403
+ 'saveAnother'
3404
+ ) && (
3405
+ <button
3406
+ className={
3407
+ styles.btn
3408
+ }
3409
+ onClick={
3410
+ handleSubmit
3411
+ }
3412
+ data-type="saveAnother"
3413
+ disabled={
3414
+ saveAnotherDisabled
3415
+ }
3416
+ style={
3417
+ saveAnotherDisabled
3418
+ ? {
3419
+ opacity: 0.5,
3420
+ cursor: 'not-allowed',
3421
+ }
3422
+ : undefined
3423
+ }
3424
+ >
3425
+ {formSettings
3426
+ .update
3427
+ .saveAnother
3428
+ ?.label ||
3429
+ 'Save & Create Another'}
3430
+ </button>
3431
+ )}
3432
+ {formSettings.update?.hasOwnProperty(
3433
+ 'saveExit'
3434
+ ) && (
3435
+ <button
3436
+ className={
3437
+ styles.btn
3438
+ }
3439
+ onClick={
3440
+ handleSubmit
3441
+ }
3442
+ data-type="saveExit"
3443
+ >
3444
+ {formSettings
3445
+ .update
3446
+ .saveExit
3447
+ ?.label ||
3448
+ 'Save & Exit'}
3449
+ </button>
3450
+ )}
3451
+ </div>
3452
+ {saveAnotherDisabled && (
3453
+ /* Always-visible note (hover
3454
+ tooltips don't exist on
3455
+ tablets) explaining why the
3456
+ button is disabled */
3457
+ <div
3458
+ className={`${styles.formItem} ${styles.fwItem}`}
3459
+ style={{
3460
+ display: 'flex',
3461
+ justifyContent:
3462
+ 'flex-end',
3463
+ }}
3464
+ >
3465
+ <div
3466
+ style={{
3467
+ display: 'flex',
3468
+ alignItems:
3469
+ 'center',
3470
+ gap: '8px',
3471
+ fontSize:
3472
+ '0.85rem',
3473
+ color: '#1e40af',
3474
+ backgroundColor:
3475
+ '#eff6ff',
3476
+ border: '1px solid #bfdbfe',
3477
+ borderRadius:
3478
+ '6px',
3479
+ padding:
3480
+ '8px 12px',
3481
+ marginTop:
3482
+ '8px',
3483
+ maxWidth:
3484
+ '100%',
3485
+ }}
3486
+ >
3487
+ <Info
3488
+ size={16}
3489
+ style={{
3490
+ flexShrink: 0,
3491
+ }}
3492
+ />
3493
+ <span>
3494
+ {saveAnotherDisableWhen?.message ||
3495
+ 'This action is currently unavailable.'}
3496
+ </span>
3497
+ </div>
3498
+ </div>
3453
3499
  )}
3454
- </div>
3500
+ </>
3455
3501
  );
3456
3502
  } else {
3457
3503
  return (
@@ -1,4 +1,5 @@
1
1
  import React, { useEffect, useRef, useState } from 'react';
2
+ import { createPortal } from 'react-dom';
2
3
  import { Tooltip } from 'react-tooltip';
3
4
  import moment from 'moment';
4
5
  import numeral from 'numeral';
@@ -138,9 +139,14 @@ const CellWithTooltip = ({
138
139
  width: '100%',
139
140
  };
140
141
 
141
- // Render tooltip with improved positioning
142
- const tooltipPortal = shouldShowTooltip && (
143
- <Tooltip
142
+ // Render the tooltip into document.body: grid rows are transformed
143
+ // (virtualization), which traps a fixed-position tooltip inside the
144
+ // row's stacking context — the next row paints over it no matter how
145
+ // high its z-index. Portaling out of the row escapes that entirely.
146
+ const tooltipPortal =
147
+ shouldShowTooltip &&
148
+ createPortal(
149
+ <Tooltip
144
150
  id={tooltipId}
145
151
  style={{
146
152
  backgroundColor: 'rgba(0, 0, 0, 0.9)',
@@ -162,10 +168,11 @@ const CellWithTooltip = ({
162
168
  noArrow={false}
163
169
  variant="dark"
164
170
  className="cell-tooltip"
165
- positionStrategy="fixed" // Use fixed positioning strategy
166
- fallbackPlacements={['bottom', 'right', 'left']} // Fallback positions if top doesn't fit
167
- />
168
- );
171
+ positionStrategy="fixed" // Use fixed positioning strategy
172
+ fallbackPlacements={['bottom', 'right', 'left']} // Fallback positions if top doesn't fit
173
+ />,
174
+ document.body
175
+ );
169
176
 
170
177
  return (
171
178
  <>
@@ -2498,6 +2498,13 @@ export const renderGalleryColumn = ({
2498
2498
  !Array.isArray(imagesArray) ||
2499
2499
  imagesArray.length === 0
2500
2500
  ) {
2501
+ // Nested child rows have their shared fields lifted onto
2502
+ // the summary parent — "No images" there would wrongly
2503
+ // suggest the item has none, so stay blank
2504
+ if (data?.__treeChild) {
2505
+ return null;
2506
+ }
2507
+
2501
2508
  return (
2502
2509
  <div
2503
2510
  style={{
@@ -2639,8 +2646,15 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
2639
2646
  sortable: false,
2640
2647
  render: ({ data }) => {
2641
2648
  if (data && column.stageConfig && column.stageConfig.key) {
2642
- // Check if the data has the specified key and it's an array
2643
- const stageData = data[column.stageConfig.key];
2649
+ // Check if the data has the specified key and it's an array;
2650
+ // fallbackKey (opt-in) covers rows whose primary key field
2651
+ // was lifted elsewhere (e.g. nested child rows showing their
2652
+ // own progress while the parent shows the whole group's)
2653
+ const stageData =
2654
+ data[column.stageConfig.key] ??
2655
+ (column.stageConfig.fallbackKey
2656
+ ? data[column.stageConfig.fallbackKey]
2657
+ : null);
2644
2658
 
2645
2659
  if (stageData && Array.isArray(stageData)) {
2646
2660
  const stageCount = stageData.length;
@@ -109,6 +109,8 @@ function useConfig(setting, tabs, filters, setRowsSelected, tableSetting) {
109
109
  type: setting.type ? setting.type : 'table',
110
110
  // Make sure to include functions if they exist in the setting
111
111
  functions: setting.functions || {},
112
+ // Optional colour legend rendered above the grid
113
+ legend: setting.legend,
112
114
  };
113
115
 
114
116
  setConfig(newConfig);
@@ -222,6 +224,8 @@ function useConfig(setting, tabs, filters, setRowsSelected, tableSetting) {
222
224
  // Preserve functions from the tab config, falling back to the original config functions
223
225
  functions:
224
226
  activeTabConfig.functions || config.functions || {},
227
+ // Tab-specific legend, falling back to the base one
228
+ legend: activeTabConfig.legend ?? setting.legend,
225
229
  };
226
230
 
227
231
  setConfig(updatedConfig);
@@ -248,6 +252,7 @@ function useConfig(setting, tabs, filters, setRowsSelected, tableSetting) {
248
252
  type: setting.type ? setting.type : 'table',
249
253
  // Preserve functions from the original config or setting
250
254
  functions: config.functions || setting.functions || {},
255
+ legend: setting.legend,
251
256
  };
252
257
 
253
258
  // Add the filter for the active child
@@ -70,6 +70,32 @@ div[role="tooltip"],
70
70
  align-items: flex-start !important; /* Align content to top */
71
71
  }
72
72
 
73
+ /* Tablet mode uses a fixed row height (natural row measurement is
74
+ disabled there), so word-wrap cells must clamp to the row instead of
75
+ spilling their overflow into the row below. The cell itself must FILL
76
+ the row (height:auto would shrink it and paint its borders as a
77
+ floating box inside the row); the innermost wrapper gets a two-line
78
+ clamp — what fits a 52px row — for a clean ellipsis. */
79
+ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap,
80
+ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > *,
81
+ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap div,
82
+ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap span {
83
+ overflow: hidden !important;
84
+ max-height: 100% !important;
85
+ }
86
+
87
+ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap {
88
+ height: 100% !important;
89
+ align-items: center !important;
90
+ padding: 6px 8px !important;
91
+ }
92
+
93
+ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
94
+ display: -webkit-box !important;
95
+ -webkit-box-orient: vertical !important;
96
+ -webkit-line-clamp: 2 !important;
97
+ }
98
+
73
99
  /* Default cell behavior - no word wrap */
74
100
  .InovuaReactDataGrid__cell:not(.cell-word-wrap) {
75
101
  white-space: nowrap !important;