@visns-studio/visns-components 6.6.2 → 6.6.4

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
@@ -607,6 +607,41 @@ The DataGrid component utilizes a modular column renderer system with 28 special
607
607
 
608
608
  All column renderers are exported from `@visns-studio/visns-components` and can be used individually or as part of the DataGrid component.
609
609
 
610
+ #### `autoGrow` — rows that outgrow the fixed tablet row height
611
+
612
+ In tablet mode (and in `large` density) the DataGrid runs at a **fixed** row
613
+ height from `DENSITY_GRID` — 52px or 60px — because the alternative, letting
614
+ the grid measure every row, depends on a rAF that a background-throttled tab
615
+ never runs, which clipped action buttons after an auto-refresh. The cost is
616
+ that a cell whose content genuinely needs a second or third line (a
617
+ `stageCounter` with enough chips to wrap) is cut off at the row height.
618
+
619
+ `"autoGrow": true` on a column buys those rows back, and only those rows:
620
+
621
+ ```jsx
622
+ {
623
+ id: 'headers',
624
+ label: 'Headers',
625
+ type: 'stageCounter',
626
+ autoGrow: true,
627
+ stageConfig: { key: 'headers', stageLabelKey: 'name' }
628
+ }
629
+ ```
630
+
631
+ - The cell's content is measured with a `ResizeObserver`; a row whose content
632
+ needs more than the fixed height grows to fit it, and every other row keeps
633
+ the fixed height exactly as before.
634
+ - The added height is the content's natural height plus the cell's **measured**
635
+ vertical padding and borders, so it tracks `--grid-cell-padding` and the
636
+ density in force rather than a constant.
637
+ - **Desktop density is unaffected** (`rowHeight` is `null` there, so rows
638
+ already measure naturally) and a grid with no `autoGrow` column takes exactly
639
+ the code path it did before.
640
+ - Group rows (`groupBy`) keep the default height; `treeGroupBy` parents and
641
+ members grow like any other row.
642
+ - Grown rows stay vertically centred, so neighbouring cells and the action
643
+ buttons line up as they always have.
644
+
610
645
  #### Dropdown Column Color Support
611
646
 
612
647
  The dropdown column type now supports automatic background color application based on the selected option's `colour` property. This feature works in both DataGrid and GenericEditableTable components.
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.6.2",
94
+ "version": "6.6.4",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -144,6 +144,7 @@ import {
144
144
  renderPhoneColumn,
145
145
  renderAbnColumn,
146
146
  } from './columns/ColumnRenderers.jsx';
147
+ import AutoGrowCell from './columns/AutoGrowCell.jsx';
147
148
  import _ from 'lodash';
148
149
 
149
150
  // Enhanced multi-line content support for data cells
@@ -711,7 +712,9 @@ const DataGrid = forwardRef(
711
712
  }
712
713
 
713
714
  if (j - i > 1) {
714
- const children = rows.slice(i, j);
715
+ const children = rows
716
+ .slice(i, j)
717
+ .map((child) => ({ ...child }));
715
718
  const parent = {
716
719
  __treeParent: true,
717
720
  id: `tree-group-${value}`,
@@ -957,6 +960,197 @@ const DataGrid = forwardRef(
957
960
  // prevents action buttons from being clipped.
958
961
  const { grid } = useDensity();
959
962
 
963
+ // ---------------------------------------------------------------
964
+ // Per-column autoGrow
965
+ // ---------------------------------------------------------------
966
+ //
967
+ // A column with `"autoGrow": true` lets the ROWS whose content does
968
+ // not fit the fixed height above grow to fit — and only those rows.
969
+ // Nothing here runs unless such a column exists AND the density gives
970
+ // a numeric `grid.rowHeight`; on desktop (`rowHeight === null`) the
971
+ // grid already measures every row, so autoGrow has nothing to add.
972
+ //
973
+ // Why this drives the grid's RowHeightManager instead of passing
974
+ // `rowHeight` as a function: the datagrid's types advertise
975
+ // `rowHeight: number | ((rowIndex) => number) | null`, but in this
976
+ // fork the function form is not implemented. `RowHeightManager`
977
+ // stores it as `rowHeightFn` and never reads it again, and
978
+ // `factory.tsx` derives `naturalRowHeight: typeof props.rowHeight !==
979
+ // 'number'` — so a function rowHeight silently turns on exactly the
980
+ // lazy per-row rAF measurement the fixed height exists to avoid.
981
+ //
982
+ // What IS implemented is the manager's per-index override map: every
983
+ // row's height comes from `rowHeightManager.getRowHeight(index)`
984
+ // (packages/react-virtual-list-pro/src/Row.tsx), which reads that map
985
+ // first and falls back to the fixed height; `setHeights(map)`
986
+ // re-indexes the offsets and emits `index`, which `VirtualList`
987
+ // listens for and answers with a full re-layout. The cells follow the
988
+ // row because Content/Row.tsx passes the row's own height down as the
989
+ // cell's `initialRowHeight` on unexpanded rows, so a grown row's other
990
+ // cells and its action buttons stay vertically centred in the taller
991
+ // box exactly as they are today.
992
+ //
993
+ // Heights are keyed by ROW ID (stable across sorting, filtering and
994
+ // virtualised scrolling) and only converted to the manager's
995
+ // index-keyed map at flush time, from the grid's own computed data —
996
+ // which includes group rows, so indexes stay right under groupBy/tree.
997
+ const autoGrowHeightsRef = useRef(new Map());
998
+ const autoGrowRowIndexRef = useRef(new Map());
999
+ const autoGrowAppliedRef = useRef(null);
1000
+ const autoGrowRafRef = useRef(0);
1001
+
1002
+ const flushAutoGrowHeights = useCallback(() => {
1003
+ autoGrowRafRef.current = 0;
1004
+
1005
+ const gridApi = gridRef.current;
1006
+ const manager = gridApi?.rowHeightManager;
1007
+ const baseRowHeight = grid.rowHeight;
1008
+
1009
+ if (!manager || typeof baseRowHeight !== 'number') {
1010
+ return;
1011
+ }
1012
+
1013
+ const heights = autoGrowHeightsRef.current;
1014
+ const next = {};
1015
+
1016
+ // The grid's computed data is the authority on index → row: it is
1017
+ // the array the virtual list indexes into, group rows included.
1018
+ // The `onRenderRow` record is only a fallback for the frame
1019
+ // before `handle` has published the computed props.
1020
+ const data = Array.isArray(gridApi?.data) ? gridApi.data : null;
1021
+
1022
+ if (data) {
1023
+ const present = new Set();
1024
+
1025
+ for (let index = 0; index < data.length; index++) {
1026
+ const row = data[index];
1027
+
1028
+ // Group rows keep the default height — they carry a title,
1029
+ // not a column's content.
1030
+ if (!row || row.__group) {
1031
+ continue;
1032
+ }
1033
+
1034
+ const rowId = row.id;
1035
+
1036
+ if (rowId === undefined || rowId === null) {
1037
+ continue;
1038
+ }
1039
+
1040
+ const key = String(rowId);
1041
+
1042
+ present.add(key);
1043
+
1044
+ const measured = heights.get(key);
1045
+
1046
+ if (measured > baseRowHeight) {
1047
+ next[index] = measured;
1048
+ }
1049
+ }
1050
+
1051
+ // Rows that left the page cannot be measured again, so drop
1052
+ // their heights rather than letting the map grow forever.
1053
+ heights.forEach((_value, key) => {
1054
+ if (!present.has(key)) {
1055
+ heights.delete(key);
1056
+ }
1057
+ });
1058
+ } else {
1059
+ autoGrowRowIndexRef.current.forEach((key, index) => {
1060
+ const measured = heights.get(key);
1061
+
1062
+ if (measured > baseRowHeight) {
1063
+ next[index] = measured;
1064
+ }
1065
+ });
1066
+ }
1067
+
1068
+ // setHeights re-indexes and emits, and the re-layout re-renders
1069
+ // the rows — which measures them again. Applying an unchanged map
1070
+ // would therefore loop, so compare before writing.
1071
+ const serialised = JSON.stringify(next);
1072
+
1073
+ if (serialised === autoGrowAppliedRef.current) {
1074
+ return;
1075
+ }
1076
+
1077
+ autoGrowAppliedRef.current = serialised;
1078
+
1079
+ if (typeof manager.setHeights === 'function') {
1080
+ manager.setHeights(next);
1081
+ }
1082
+ }, [grid]);
1083
+
1084
+ // One flush per frame: a page of 100 rows measuring on mount is 100
1085
+ // reports and one re-layout.
1086
+ const scheduleAutoGrowFlush = useCallback(() => {
1087
+ if (autoGrowRafRef.current) {
1088
+ return;
1089
+ }
1090
+
1091
+ if (typeof requestAnimationFrame === 'undefined') {
1092
+ flushAutoGrowHeights();
1093
+ return;
1094
+ }
1095
+
1096
+ autoGrowRafRef.current =
1097
+ requestAnimationFrame(flushAutoGrowHeights);
1098
+ }, [flushAutoGrowHeights]);
1099
+
1100
+ /** `AutoGrowCell`'s callback: one row's natural height, in px. */
1101
+ const reportAutoGrowHeight = useCallback(
1102
+ (rowId, height, force) => {
1103
+ if (
1104
+ rowId === undefined ||
1105
+ rowId === null ||
1106
+ !Number.isFinite(height)
1107
+ ) {
1108
+ return;
1109
+ }
1110
+
1111
+ const key = String(rowId);
1112
+ const previous = autoGrowHeightsRef.current.get(key);
1113
+ const changed =
1114
+ previous === undefined || Math.abs(previous - height) > 1;
1115
+
1116
+ if (changed) {
1117
+ autoGrowHeightsRef.current.set(key, height);
1118
+ }
1119
+
1120
+ // `force` covers the mount/row-changed case: the height may be
1121
+ // identical while the INDEX it has to be applied at is not.
1122
+ if (changed || force) {
1123
+ scheduleAutoGrowFlush();
1124
+ }
1125
+ },
1126
+ [scheduleAutoGrowFlush]
1127
+ );
1128
+
1129
+ useEffect(
1130
+ () => () => {
1131
+ if (
1132
+ autoGrowRafRef.current &&
1133
+ typeof cancelAnimationFrame !== 'undefined'
1134
+ ) {
1135
+ cancelAnimationFrame(autoGrowRafRef.current);
1136
+ autoGrowRafRef.current = 0;
1137
+ }
1138
+ },
1139
+ []
1140
+ );
1141
+
1142
+ // Leaving the fixed-height densities hands row heights back to the
1143
+ // grid; anything measured under the old density is meaningless.
1144
+ useEffect(() => {
1145
+ if (typeof grid.rowHeight === 'number') {
1146
+ return;
1147
+ }
1148
+
1149
+ autoGrowHeightsRef.current.clear();
1150
+ autoGrowRowIndexRef.current.clear();
1151
+ autoGrowAppliedRef.current = null;
1152
+ }, [grid]);
1153
+
960
1154
  // Effect to handle autoRefresh setting
961
1155
  useEffect(() => {
962
1156
  if (ajaxSetting && ajaxSetting.autoRefresh !== undefined) {
@@ -1919,7 +2113,11 @@ const DataGrid = forwardRef(
1919
2113
  }
1920
2114
 
1921
2115
  // Show confirmation dialog for other actions to prevent accidental clicks
1922
- const confirmMessage = `Are you sure you want to perform "${iconConfig.label}" action on group "${groupValue}" (${groupRows.length} rows)?`;
2116
+ const confirmMessage = `Are you sure you want to perform "${
2117
+ iconConfig.label
2118
+ }" action on group "${getGroupDisplayLabel(groupValue)}" (${
2119
+ groupRows.length
2120
+ } rows)?`;
1923
2121
 
1924
2122
  confirmDialog({
1925
2123
  title: 'Confirm Group Action',
@@ -1957,6 +2155,28 @@ const DataGrid = forwardRef(
1957
2155
  );
1958
2156
  };
1959
2157
 
2158
+ /**
2159
+ * `groupBySetting.labelKey` is display-only: when set, the group row
2160
+ * shows the named field taken from the group's first row instead of
2161
+ * the raw group value (falling back to it when the field is empty).
2162
+ * The group KEY is still `groupValue` — it decides membership,
2163
+ * selection, collapse state, `handleGroupAction`/`shouldShowGroupAction`
2164
+ * and what the backend receives (`groupLabel`).
2165
+ */
2166
+ const getGroupDisplayLabel = (groupValue) => {
2167
+ const labelKey = ajaxSetting?.groupBySetting?.labelKey;
2168
+
2169
+ if (!labelKey) {
2170
+ return groupValue;
2171
+ }
2172
+
2173
+ const label = getGroupRows(groupValue)[0]?.[labelKey];
2174
+
2175
+ return label === null || label === undefined || label === ''
2176
+ ? groupValue
2177
+ : label;
2178
+ };
2179
+
1960
2180
  /**
1961
2181
  * The show-condition grammar shared by group action icons and row
1962
2182
  * action icons:
@@ -2094,7 +2314,9 @@ const DataGrid = forwardRef(
2094
2314
  iconConfig.fetch.messageFromResponse &&
2095
2315
  result?.data?.message
2096
2316
  ? result.data.message
2097
- : `${iconConfig.label} - Group: ${groupValue} completed successfully`
2317
+ : `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2318
+ groupValue
2319
+ )} completed successfully`
2098
2320
  );
2099
2321
 
2100
2322
  // Reload grid data if needed
@@ -2111,7 +2333,9 @@ const DataGrid = forwardRef(
2111
2333
  } catch (error) {
2112
2334
  console.error('Group action fetch error:', error);
2113
2335
  toast.error(
2114
- `${iconConfig.label} - Group: ${groupValue} failed: ${error.message}`
2336
+ `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2337
+ groupValue
2338
+ )} failed: ${error.message}`
2115
2339
  );
2116
2340
  }
2117
2341
  } else if (iconConfig.formModal) {
@@ -2135,7 +2359,11 @@ const DataGrid = forwardRef(
2135
2359
  // Fallback to existing switch logic if no fetch config
2136
2360
  // Skip toast notification for edit actions
2137
2361
  if (iconConfig.id !== 'edit') {
2138
- toast.info(`${iconConfig.label} - Group: ${groupValue}`);
2362
+ toast.info(
2363
+ `${iconConfig.label} - Group: ${getGroupDisplayLabel(
2364
+ groupValue
2365
+ )}`
2366
+ );
2139
2367
  }
2140
2368
 
2141
2369
  switch (iconConfig.id) {
@@ -2511,6 +2739,14 @@ const DataGrid = forwardRef(
2511
2739
  timestamp: new Date().toISOString(),
2512
2740
  });
2513
2741
 
2742
+ // Synthetic tree parent rows are group summaries with a
2743
+ // fabricated string id — they must never open the update modal
2744
+ // or follow links. Their own controls (chevron, group action
2745
+ // icons) stopPropagation before this handler fires.
2746
+ if (rowProps.data?.__treeParent) {
2747
+ return;
2748
+ }
2749
+
2514
2750
  const cellElement = event.target.closest(
2515
2751
  '.InovuaReactDataGrid__cell'
2516
2752
  );
@@ -2674,6 +2910,26 @@ const DataGrid = forwardRef(
2674
2910
 
2675
2911
  const onRenderRow = useCallback(
2676
2912
  (rowProps) => {
2913
+ // Index → row id, the fallback map autoGrow flushes from
2914
+ // before the grid has published its computed data (see the
2915
+ // autoGrow block above). Group rows are skipped: they keep the
2916
+ // default height.
2917
+ const autoGrowIndex =
2918
+ rowProps?.rowIndex ?? rowProps?.realIndex;
2919
+
2920
+ if (
2921
+ typeof autoGrowIndex === 'number' &&
2922
+ rowProps?.data &&
2923
+ !rowProps.data.__group &&
2924
+ rowProps.data.id !== undefined &&
2925
+ rowProps.data.id !== null
2926
+ ) {
2927
+ autoGrowRowIndexRef.current.set(
2928
+ autoGrowIndex,
2929
+ String(rowProps.data.id)
2930
+ );
2931
+ }
2932
+
2677
2933
  // save the original handlers to be called later
2678
2934
  const { onClick } = rowProps;
2679
2935
 
@@ -4584,16 +4840,91 @@ const DataGrid = forwardRef(
4584
4840
  };
4585
4841
  };
4586
4842
 
4843
+ /**
4844
+ * `"autoGrow": true` on a column definition.
4845
+ *
4846
+ * Opt-in, and only meaningful where the grid runs at a
4847
+ * fixed row height — on desktop (`grid.rowHeight === null`)
4848
+ * rows already measure naturally, so this returns the
4849
+ * column untouched and the code path below is the one that
4850
+ * has always run.
4851
+ *
4852
+ * Outermost decorator on purpose: it has to measure the
4853
+ * FINAL content of the cell, including the tree indent and
4854
+ * any row-action icons the decorators below add.
4855
+ */
4856
+ const decorateAutoGrow = (gridColumn, columnDef) => {
4857
+ if (
4858
+ columnDef?.autoGrow !== true ||
4859
+ typeof grid.rowHeight !== 'number' ||
4860
+ !gridColumn
4861
+ ) {
4862
+ return gridColumn;
4863
+ }
4864
+
4865
+ const originalRender = gridColumn.render;
4866
+ const existingClassName = gridColumn.className;
4867
+
4868
+ // The class relaxes the nowrap/ellipsis clamp that
4869
+ // global-datagrid.css puts on every non-wrapping cell;
4870
+ // without it the content can never take a second line
4871
+ // and so can never be taller than the row.
4872
+ const withAutoGrowClass = (base) =>
4873
+ [base, 'cell-auto-grow'].filter(Boolean).join(' ');
4874
+
4875
+ return {
4876
+ ...gridColumn,
4877
+ className:
4878
+ typeof existingClassName === 'function'
4879
+ ? (...args) =>
4880
+ withAutoGrowClass(
4881
+ existingClassName(...args)
4882
+ )
4883
+ : withAutoGrowClass(existingClassName),
4884
+ render: (arg) => {
4885
+ const content = originalRender
4886
+ ? originalRender(arg)
4887
+ : arg?.value ?? null;
4888
+
4889
+ const row = arg?.data;
4890
+
4891
+ // Group rows keep the default height, and an
4892
+ // empty cell has nothing to grow for.
4893
+ if (
4894
+ !row ||
4895
+ row.__group ||
4896
+ content === null ||
4897
+ content === undefined ||
4898
+ content === false
4899
+ ) {
4900
+ return content;
4901
+ }
4902
+
4903
+ return (
4904
+ <AutoGrowCell
4905
+ rowId={row.id}
4906
+ onMeasure={reportAutoGrowHeight}
4907
+ >
4908
+ {content}
4909
+ </AutoGrowCell>
4910
+ );
4911
+ },
4912
+ };
4913
+ };
4914
+
4587
4915
  // decorateTreeChildIndent sits INSIDE
4588
4916
  // decorateTreeParentActions: the latter draws parent rows
4589
4917
  // itself and delegates everything else downward, so the
4590
4918
  // member indent has to be the render it delegates to.
4591
4919
  const newColumns = columns.map((column) =>
4592
- decorateColumnRowActions(
4593
- decorateTreeParentActions(
4594
- decorateTreeChildIndent(
4595
- guardTreeParentRender(
4596
- renderColumn(column),
4920
+ decorateAutoGrow(
4921
+ decorateColumnRowActions(
4922
+ decorateTreeParentActions(
4923
+ decorateTreeChildIndent(
4924
+ guardTreeParentRender(
4925
+ renderColumn(column),
4926
+ column
4927
+ ),
4597
4928
  column
4598
4929
  ),
4599
4930
  column
@@ -5318,126 +5649,130 @@ const DataGrid = forwardRef(
5318
5649
  aria-label="Select all items in this group"
5319
5650
  />
5320
5651
  )}
5321
- <button
5322
- className={`group-expand-btn ${styles.groupExpandBtn}`}
5323
- onClick={(e) => {
5324
- e.stopPropagation();
5325
- if (
5326
- typeof toggleGroup ===
5327
- 'function'
5328
- ) {
5329
- toggleGroup();
5652
+ <div
5653
+ className={`group-controls ${styles.groupControls}`}
5654
+ >
5655
+ <button
5656
+ className={`group-expand-btn ${styles.groupExpandBtn}`}
5657
+ onClick={(e) => {
5658
+ e.stopPropagation();
5659
+ if (
5660
+ typeof toggleGroup ===
5661
+ 'function'
5662
+ ) {
5663
+ toggleGroup();
5664
+ }
5665
+ }}
5666
+ onTouchStart={(e) => {
5667
+ e.stopPropagation();
5668
+ handleTouchStart(
5669
+ e,
5670
+ 'group-expand',
5671
+ collapsed
5672
+ ? 'Expand group'
5673
+ : 'Collapse group'
5674
+ );
5675
+ }}
5676
+ onTouchEnd={
5677
+ handleTouchEnd
5330
5678
  }
5331
- }}
5332
- onTouchStart={(e) => {
5333
- e.stopPropagation();
5334
- handleTouchStart(
5335
- e,
5336
- 'group-expand',
5679
+ onTouchMove={
5680
+ handleTouchMove
5681
+ }
5682
+ data-tooltip-id="system-tooltip"
5683
+ data-tooltip-content={
5337
5684
  collapsed
5338
5685
  ? 'Expand group'
5339
5686
  : 'Collapse group'
5340
- );
5341
- }}
5342
- onTouchEnd={
5343
- handleTouchEnd
5344
- }
5345
- onTouchMove={
5346
- handleTouchMove
5347
- }
5348
- data-tooltip-id="system-tooltip"
5349
- data-tooltip-content={
5350
- collapsed
5351
- ? 'Expand group'
5352
- : 'Collapse group'
5353
- }
5354
- >
5355
- {collapsed
5356
- ? '+'
5357
- : '−'}
5358
- </button>
5359
- {/* Dynamic group action buttons */}
5360
- {ajaxSetting?.groupBySetting?.icons?.map(
5361
- (
5362
- iconConfig,
5363
- index
5364
- ) => {
5365
- // Check if this icon should be shown based on show conditions
5366
- if (
5367
- !shouldShowGroupAction(
5368
- iconConfig,
5369
- groupValue
5370
- )
5371
- ) {
5372
- return null;
5373
5687
  }
5688
+ >
5689
+ {collapsed
5690
+ ? '+'
5691
+ : '−'}
5692
+ </button>
5693
+ {/* Dynamic group action buttons */}
5694
+ {ajaxSetting?.groupBySetting?.icons?.map(
5695
+ (
5696
+ iconConfig,
5697
+ index
5698
+ ) => {
5699
+ // Check if this icon should be shown based on show conditions
5700
+ if (
5701
+ !shouldShowGroupAction(
5702
+ iconConfig,
5703
+ groupValue
5704
+ )
5705
+ ) {
5706
+ return null;
5707
+ }
5374
5708
 
5375
- const IconComponent =
5376
- getGroupIconComponent(
5377
- iconConfig.id
5378
- );
5709
+ const IconComponent =
5710
+ getGroupIconComponent(
5711
+ iconConfig.id
5712
+ );
5379
5713
 
5380
- // Generate different color classes for multiple buttons
5381
- const colorClasses =
5382
- [
5383
- 'success',
5384
- 'warning',
5385
- 'info',
5386
- 'purple',
5387
- 'pink',
5388
- ];
5389
- const colorClass =
5390
- colorClasses[
5391
- index %
5392
- colorClasses.length
5393
- ];
5394
-
5395
- return (
5396
- <button
5397
- key={`group-action-${index}`}
5398
- className={`group-action-btn group-${iconConfig.id}-btn ${styles.groupActionBtn} ${styles[colorClass]}`}
5399
- onClick={(
5400
- e
5401
- ) => {
5402
- e.stopPropagation();
5403
- handleGroupAction(
5404
- groupValue,
5405
- iconConfig
5406
- );
5407
- }}
5408
- onTouchStart={(
5409
- e
5410
- ) => {
5411
- e.stopPropagation();
5412
- handleTouchStart(
5413
- e,
5414
- `group-${iconConfig.id}-${index}`,
5415
- iconConfig.label
5416
- );
5417
- }}
5418
- onTouchEnd={
5419
- handleTouchEnd
5420
- }
5421
- onTouchMove={
5422
- handleTouchMove
5423
- }
5424
- data-tooltip-id="system-tooltip"
5425
- data-tooltip-content={
5426
- iconConfig.label
5427
- }
5428
- >
5429
- <IconComponent
5430
- strokeWidth={
5431
- 2
5714
+ // Generate different color classes for multiple buttons
5715
+ const colorClasses =
5716
+ [
5717
+ 'success',
5718
+ 'warning',
5719
+ 'info',
5720
+ 'purple',
5721
+ 'pink',
5722
+ ];
5723
+ const colorClass =
5724
+ colorClasses[
5725
+ index %
5726
+ colorClasses.length
5727
+ ];
5728
+
5729
+ return (
5730
+ <button
5731
+ key={`group-action-${index}`}
5732
+ className={`group-action-btn group-${iconConfig.id}-btn ${styles.groupActionBtn} ${styles[colorClass]}`}
5733
+ onClick={(
5734
+ e
5735
+ ) => {
5736
+ e.stopPropagation();
5737
+ handleGroupAction(
5738
+ groupValue,
5739
+ iconConfig
5740
+ );
5741
+ }}
5742
+ onTouchStart={(
5743
+ e
5744
+ ) => {
5745
+ e.stopPropagation();
5746
+ handleTouchStart(
5747
+ e,
5748
+ `group-${iconConfig.id}-${index}`,
5749
+ iconConfig.label
5750
+ );
5751
+ }}
5752
+ onTouchEnd={
5753
+ handleTouchEnd
5432
5754
  }
5433
- size={
5434
- 14
5755
+ onTouchMove={
5756
+ handleTouchMove
5435
5757
  }
5436
- />
5437
- </button>
5438
- );
5439
- }
5440
- ) || null}
5758
+ data-tooltip-id="system-tooltip"
5759
+ data-tooltip-content={
5760
+ iconConfig.label
5761
+ }
5762
+ >
5763
+ <IconComponent
5764
+ strokeWidth={
5765
+ 2
5766
+ }
5767
+ size={
5768
+ 14
5769
+ }
5770
+ />
5771
+ </button>
5772
+ );
5773
+ }
5774
+ ) || null}
5775
+ </div>
5441
5776
  <div
5442
5777
  className={
5443
5778
  styles.groupInfo
@@ -5448,7 +5783,9 @@ const DataGrid = forwardRef(
5448
5783
  styles.groupValue
5449
5784
  }
5450
5785
  >
5451
- {groupValue}
5786
+ {getGroupDisplayLabel(
5787
+ groupValue
5788
+ )}
5452
5789
  {/* Important marker */}
5453
5790
  {hasImportantMarker && (
5454
5791
  <AlertCircle
@@ -1310,6 +1310,26 @@ function Form({
1310
1310
  }
1311
1311
  }
1312
1312
 
1313
+ // A form that never fetched its record (see the columnId > 0
1314
+ // gate on the record GET) has no primary key — submitting
1315
+ // would PUT/DELETE against `${url}/undefined`. Abort loudly
1316
+ // instead.
1317
+ if (
1318
+ ['update', 'delete'].includes(formType) &&
1319
+ !bulkEditMode &&
1320
+ !formSettings.json &&
1321
+ !formSettings.customUrl
1322
+ ) {
1323
+ const pk = formData?.[formSettings.primaryKey];
1324
+
1325
+ if (pk == null || pk === '' || Number.isNaN(Number(pk))) {
1326
+ toast.error(
1327
+ 'This record could not be loaded, so it cannot be saved. Please close the form and reopen it from its row.'
1328
+ );
1329
+ return;
1330
+ }
1331
+ }
1332
+
1313
1333
  let fields = formSettings.fields;
1314
1334
  let _inputClass = inputClass;
1315
1335
  let validation = '';
@@ -2199,32 +2219,67 @@ function Form({
2199
2219
  fetchTable(createdItem);
2200
2220
  }
2201
2221
  } else {
2202
- const saveAnotherUrl = `${
2203
- saveAnother.url
2204
- }/${formData[saveAnother.dataKey]}`;
2205
- const resSaveAnother =
2206
- await CustomFetch(
2207
- saveAnotherUrl,
2208
- saveAnother.method,
2209
- processedData
2222
+ // Without a record id the follow-up
2223
+ // would hit `${url}/undefined`, so
2224
+ // skip the request and report it
2225
+ // the same way a failed one is
2226
+ const saveAnotherId =
2227
+ formData?.[saveAnother.dataKey];
2228
+
2229
+ if (
2230
+ saveAnotherId == null ||
2231
+ saveAnotherId === '' ||
2232
+ Number.isNaN(
2233
+ Number(saveAnotherId)
2234
+ )
2235
+ ) {
2236
+ saveAnotherFailed = true;
2237
+ toast.error(
2238
+ 'Unable to run the follow-up action: the record id is missing.'
2210
2239
  );
2211
2240
 
2212
- if (resSaveAnother.data.error === '') {
2213
- toast.success(saveAnother.message);
2214
2241
  if (fetchTable) {
2215
2242
  const createdItem =
2216
- res.data.data || res.data;
2243
+ res.data.data ||
2244
+ res.data;
2217
2245
  fetchTable(createdItem);
2218
2246
  }
2219
2247
  } else {
2220
- saveAnotherFailed = true;
2221
- toast.error(
2222
- resSaveAnother.data.error
2223
- );
2224
- if (fetchTable) {
2225
- const createdItem =
2226
- res.data.data || res.data;
2227
- fetchTable(createdItem);
2248
+ const saveAnotherUrl = `${
2249
+ saveAnother.url
2250
+ }/${saveAnotherId}`;
2251
+ const resSaveAnother =
2252
+ await CustomFetch(
2253
+ saveAnotherUrl,
2254
+ saveAnother.method,
2255
+ processedData
2256
+ );
2257
+
2258
+ if (
2259
+ resSaveAnother.data
2260
+ .error === ''
2261
+ ) {
2262
+ toast.success(
2263
+ saveAnother.message
2264
+ );
2265
+ if (fetchTable) {
2266
+ const createdItem =
2267
+ res.data.data ||
2268
+ res.data;
2269
+ fetchTable(createdItem);
2270
+ }
2271
+ } else {
2272
+ saveAnotherFailed = true;
2273
+ toast.error(
2274
+ resSaveAnother.data
2275
+ .error
2276
+ );
2277
+ if (fetchTable) {
2278
+ const createdItem =
2279
+ res.data.data ||
2280
+ res.data;
2281
+ fetchTable(createdItem);
2282
+ }
2228
2283
  }
2229
2284
  }
2230
2285
  }
@@ -36,7 +36,7 @@ const CustomOption = (props) => {
36
36
 
37
37
  // Custom MultiValue component to display description for selected values
38
38
  const CustomMultiValue = (props) => {
39
- const { children, data, removeProps, settings, ...rest } = props;
39
+ const { children, data, settings, ...rest } = props;
40
40
 
41
41
  return (
42
42
  <components.MultiValue {...rest} data={data}>
@@ -62,61 +62,18 @@ const CustomMultiValue = (props) => {
62
62
  );
63
63
  };
64
64
 
65
- // Custom MultiValueRemove component with proper removal handling
65
+ // Custom MultiValueRemove: fixed values cannot be deselected — render no
66
+ // × at all (the module SCSS forces `display` on the remove button with
67
+ // !important, so hiding it from the styles function is not enough).
68
+ // Removal itself is react-select's own behaviour: CustomMultiValue
69
+ // forwards removeProps, so innerProps already carries working
70
+ // onClick/onTouchEnd handlers.
66
71
  const CustomMultiValueRemove = (props) => {
67
- // Fixed values cannot be deselected: render no × at all — the module
68
- // SCSS forces `display` on the remove button with !important, so
69
- // hiding it from the styles function is not enough.
70
72
  if (props.data?.isFixed) {
71
73
  return null;
72
74
  }
73
75
 
74
- const handleRemove = (e) => {
75
- e.stopPropagation();
76
-
77
- // Try to find the data by looking at the DOM structure
78
- const removeButton = e.currentTarget;
79
- const multiValueContainer = removeButton.closest('.visns-select__multi-value');
80
-
81
- if (multiValueContainer) {
82
- // Get the index of this multi-value item
83
- const allMultiValues = removeButton.closest('.visns-select__value-container').querySelectorAll('.visns-select__multi-value');
84
- const index = Array.from(allMultiValues).indexOf(multiValueContainer);
85
-
86
- console.log('MultiSelect: Found index:', index);
87
-
88
- // Get current value and remove the item at this index
89
- const currentValue = props.selectProps?.value || [];
90
- console.log('MultiSelect: Current value:', currentValue);
91
-
92
- if (index >= 0 && index < currentValue.length) {
93
- const itemToRemove = currentValue[index];
94
- const newValue = currentValue.filter((_, i) => i !== index);
95
-
96
- console.log('MultiSelect: Removing item at index', index, ':', itemToRemove);
97
- console.log('MultiSelect: New value:', newValue);
98
-
99
- // Call onChange with the filtered value and remove action
100
- if (props.selectProps?.onChange) {
101
- props.selectProps.onChange(newValue, {
102
- action: 'remove-value',
103
- removedValue: itemToRemove
104
- });
105
- }
106
- }
107
- }
108
- };
109
-
110
- return (
111
- <components.MultiValueRemove
112
- {...props}
113
- innerProps={{
114
- ...props.innerProps,
115
- onClick: handleRemove,
116
- onMouseDown: (e) => e.stopPropagation()
117
- }}
118
- />
119
- );
76
+ return <components.MultiValueRemove {...props} />;
120
77
  };
121
78
 
122
79
  // Custom NoOptionsMessage component to guide users about creation capability
@@ -333,15 +290,6 @@ function MultiSelect({
333
290
  menuPlacement={menuPlacement}
334
291
  onMenuOpen={handleMenuOpen}
335
292
  onChange={(inputValue, action) => {
336
- // Debug logging for delete actions
337
- if (action.action === 'remove-value') {
338
- console.log('MultiSelect: Individual item delete:', {
339
- removedValue: action.removedValue,
340
- newValue: inputValue,
341
- action: action
342
- });
343
- }
344
-
345
293
  // Values seeded with isFixed represent memberships this
346
294
  // field is not allowed to take away (e.g. a tag's existing
347
295
  // headers in an add-only group modal): ignore their removal
@@ -0,0 +1,141 @@
1
+ import { useEffect, useRef } from 'react';
2
+
3
+ /**
4
+ * The measuring wrapper behind a column's `"autoGrow": true`.
5
+ *
6
+ * Why it exists
7
+ * -------------
8
+ * In tablet mode (and in `large` density generally) the grid runs with a
9
+ * FIXED `rowHeight` — see the density table in `utils/useDensity.js` and the
10
+ * comment above `rowHeight={grid.rowHeight}` in `DataGrid.jsx`. A fixed height
11
+ * is what keeps action buttons from being clipped after an auto-refresh on an
12
+ * idle tab, because Inovua's alternative (`rowHeight={null}`) measures every
13
+ * row with a lazily-scheduled rAF, which a background-throttled tab never runs.
14
+ *
15
+ * The cost of that trade is that a cell whose content genuinely needs three
16
+ * lines — a `stageCounter` column with enough chips to wrap twice — gets cut
17
+ * off at the row's height. `autoGrow` buys back JUST those rows: this wrapper
18
+ * measures the content's natural height and hands it up, and the grid raises
19
+ * that ONE row to fit. Every other row keeps the fixed height, so the property
20
+ * that made the fixed height worth having (no measurement in the common path)
21
+ * is preserved.
22
+ *
23
+ * How the measurement stays honest
24
+ * --------------------------------
25
+ * The cell itself is height-clamped (`.InovuaReactDataGrid__cell` gets an
26
+ * inline `height` from the row's height, and `global-datagrid.css` gives it
27
+ * `overflow: hidden`), so measuring the CELL would only ever report the clamp
28
+ * back. This `div` is a plain block inside the cell's content wrapper with no
29
+ * height of its own, so its `scrollHeight` is the content's natural height
30
+ * whatever the cell around it is doing.
31
+ *
32
+ * The number reported is a ROW height, not a content height: the owning cell's
33
+ * vertical padding and borders are read off `getComputedStyle` and added, so
34
+ * the figure tracks `--grid-cell-padding` and the density that sets it instead
35
+ * of a constant that would drift the moment either changed.
36
+ *
37
+ * @param {object} props
38
+ * @param {*} props.rowId id of the row this cell belongs to; the
39
+ * key the grid stores the height under.
40
+ * @param {Function} props.onMeasure `(rowId, heightPx, force) => void`
41
+ * @param {import('react').ReactNode} props.children the column's own render.
42
+ */
43
+ const AutoGrowCell = ({ rowId, onMeasure, children }) => {
44
+ const nodeRef = useRef(null);
45
+ // Last height handed up, so a ResizeObserver that fires on every column
46
+ // resize does not re-report a height nothing has changed about.
47
+ const lastReportedRef = useRef(null);
48
+
49
+ useEffect(() => {
50
+ const node = nodeRef.current;
51
+
52
+ if (
53
+ !node ||
54
+ rowId === undefined ||
55
+ rowId === null ||
56
+ typeof onMeasure !== 'function'
57
+ ) {
58
+ return undefined;
59
+ }
60
+
61
+ /**
62
+ * Padding + borders of the cell this content sits in. Measured rather
63
+ * than assumed: `--grid-cell-padding` is density-driven and a caller
64
+ * can override it per grid.
65
+ */
66
+ const cellChrome = () => {
67
+ if (
68
+ typeof window === 'undefined' ||
69
+ typeof window.getComputedStyle !== 'function' ||
70
+ typeof node.closest !== 'function'
71
+ ) {
72
+ return 0;
73
+ }
74
+
75
+ const cell = node.closest('.InovuaReactDataGrid__cell');
76
+
77
+ if (!cell) {
78
+ return 0;
79
+ }
80
+
81
+ const cs = window.getComputedStyle(cell);
82
+ const sum =
83
+ (parseFloat(cs.paddingTop) || 0) +
84
+ (parseFloat(cs.paddingBottom) || 0) +
85
+ (parseFloat(cs.borderTopWidth) || 0) +
86
+ (parseFloat(cs.borderBottomWidth) || 0);
87
+
88
+ return Number.isFinite(sum) ? sum : 0;
89
+ };
90
+
91
+ /**
92
+ * @param {boolean} force report even when the height has not moved —
93
+ * used on mount and whenever the row under this cell changes,
94
+ * because the grid keys heights by row index and an unchanged
95
+ * height at a NEW index still has to be re-applied.
96
+ */
97
+ const report = (force) => {
98
+ if (!nodeRef.current) {
99
+ return;
100
+ }
101
+
102
+ const natural = nodeRef.current.scrollHeight || 0;
103
+
104
+ if (!natural) {
105
+ return;
106
+ }
107
+
108
+ const height = Math.ceil(natural + cellChrome());
109
+ const last = lastReportedRef.current;
110
+
111
+ if (!force && last !== null && Math.abs(last - height) <= 1) {
112
+ return;
113
+ }
114
+
115
+ lastReportedRef.current = height;
116
+ onMeasure(rowId, height, !!force);
117
+ };
118
+
119
+ report(true);
120
+
121
+ // No ResizeObserver (jsdom, older Safari): the mount measurement above
122
+ // still applies, the cell just will not follow later reflows.
123
+ if (typeof ResizeObserver === 'undefined') {
124
+ return undefined;
125
+ }
126
+
127
+ const observer = new ResizeObserver(() => report(false));
128
+
129
+ observer.observe(node);
130
+
131
+ return () => observer.disconnect();
132
+ }, [rowId, onMeasure]);
133
+
134
+ return (
135
+ <div ref={nodeRef} className="autoGrowCell" style={{ width: '100%' }}>
136
+ {children}
137
+ </div>
138
+ );
139
+ };
140
+
141
+ export default AutoGrowCell;
@@ -1011,6 +1011,16 @@
1011
1011
  position: relative;
1012
1012
  }
1013
1013
 
1014
+ // The expand toggle and the group-action buttons as one block, so a
1015
+ // consuming stylesheet can give the controls a minimum width (lining the
1016
+ // label up with a column) while still letting a row with many buttons push
1017
+ // the label right instead of running underneath it.
1018
+ .groupControls {
1019
+ display: flex;
1020
+ align-items: center;
1021
+ flex: 0 0 auto;
1022
+ }
1023
+
1014
1024
  .groupHeaderCheckbox {
1015
1025
  width: 22px;
1016
1026
  height: 22px;
@@ -110,6 +110,35 @@ body.tablet-mode .InovuaReactDataGrid__cell.cell-word-wrap > div {
110
110
  overflow: hidden !important;
111
111
  }
112
112
 
113
+ /* Auto-grow cells — a column with `"autoGrow": true`.
114
+
115
+ The opposite trade to the tablet word-wrap clamp above: instead of the cell
116
+ clamping its content to the row, the ROW grows to its content (DataGrid
117
+ measures it through AutoGrowCell and raises that one row via the grid's
118
+ RowHeightManager). So the content must be allowed to take a second and third
119
+ line — the nowrap/ellipsis rule that applies to every other non-wrapping
120
+ cell would otherwise make the content one line tall forever and there would
121
+ be nothing to grow for.
122
+
123
+ The CELL keeps `overflow: hidden`: between the frame the content grows in
124
+ and the frame the row grows in, the extra lines are clipped at the row
125
+ boundary rather than spilling over the row below, which is what the grid
126
+ looks like today. The inner wrapper stays `overflow: visible` so nothing
127
+ clips once the row has caught up, and `align-self: center` on the grid's own
128
+ content wrapper keeps the value vertically centred in the taller row, next
129
+ to the neighbouring cells and the action buttons. */
130
+ .InovuaReactDataGrid__cell.cell-auto-grow,
131
+ .InovuaReactDataGrid__cell.cell-auto-grow .InovuaReactDataGrid__cell__content,
132
+ .InovuaReactDataGrid__cell.cell-auto-grow .autoGrowCell {
133
+ white-space: normal !important;
134
+ text-overflow: clip !important;
135
+ }
136
+
137
+ .InovuaReactDataGrid__cell.cell-auto-grow .InovuaReactDataGrid__cell__content,
138
+ .InovuaReactDataGrid__cell.cell-auto-grow .autoGrowCell {
139
+ overflow: visible !important;
140
+ }
141
+
113
142
  /* NOTE: this fork renders header cells as .InovuaReactDataGrid__column-header
114
143
  (there is no __header-cell class in its DOM), so header padding is left to
115
144
  the module stylesheet; a __header-cell padding rule that used to sit here