@visns-studio/visns-components 6.0.2 → 6.0.3

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
@@ -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.0.2",
94
+ "version": "6.0.3",
95
95
  "description": "Various packages to assist in the development of our Custom Applications.",
96
96
  "main": "src/index.js",
97
97
  "files": [
@@ -73,6 +73,8 @@ import {
73
73
  Volume2,
74
74
  AlertTriangle,
75
75
  AlertCircle,
76
+ CheckCheck,
77
+ Plus,
76
78
  } from 'lucide-react';
77
79
  import styles from './styles/DataGrid.module.scss';
78
80
 
@@ -403,6 +405,14 @@ const DataGrid = forwardRef(
403
405
  /** Group expansion state */
404
406
  const [collapsedGroups, setCollapsedGroups] = useState({});
405
407
 
408
+ /**
409
+ * Collapse state for treeGroupBy blocks, keyed by group value.
410
+ * Separate from `collapsedGroups` (which drives the grid's own
411
+ * grouping) because tree children are hidden by filtering the data
412
+ * rather than by the grid.
413
+ */
414
+ const [collapsedTreeGroups, setCollapsedTreeGroups] = useState({});
415
+
406
416
  /** Track if groups have been initialized to prevent re-initialization */
407
417
  const groupsInitializedRef = useRef(false);
408
418
 
@@ -604,13 +614,15 @@ const DataGrid = forwardRef(
604
614
  }
605
615
 
606
616
  // Opt-in grouped display (ajaxSetting.treeGroupBy {key,
607
- // column, parentFields, countLabel}): consecutive rows
608
- // sharing a non-empty `key` value get a synthetic summary
609
- // parent row inserted above them, always visible — no
610
- // expand/collapse. `parentFields` are lifted from the
611
- // first row onto the parent and blanked on the children,
612
- // so shared details display once; children get an "↳"
613
- // prefix in the display column. Single rows stay plain.
617
+ // column, parentFields, countLabel, collapsible,
618
+ // defaultCollapsed, icons}): consecutive rows sharing a
619
+ // non-empty `key` value get a synthetic summary parent
620
+ // row inserted above them. `parentFields` are lifted from
621
+ // the first row onto the parent and blanked on the
622
+ // children, so shared details display once; children get
623
+ // an "↳" prefix in the display column. Single rows stay
624
+ // plain — unlike the grid's own groupBy, which forces a
625
+ // header row onto every key.
614
626
  // Runs after dataRef is set so selection/group helpers
615
627
  // keep operating on the real rows only.
616
628
  if (
@@ -622,6 +634,8 @@ const DataGrid = forwardRef(
622
634
  column: treeDisplayColumn,
623
635
  parentFields = [],
624
636
  countLabel,
637
+ collapsible,
638
+ defaultCollapsed,
625
639
  } = ajaxSetting.treeGroupBy;
626
640
  const rows = res.data;
627
641
  const grouped = [];
@@ -689,7 +703,25 @@ const DataGrid = forwardRef(
689
703
  );
690
704
  }
691
705
 
692
- grouped.push(parent, ...children);
706
+ if (collapsible) {
707
+ // Groups the user has never touched sit at
708
+ // the configured default, so an auto-
709
+ // refresh cannot spring them open or shut
710
+ const isCollapsed =
711
+ collapsedTreeGroups[value] ??
712
+ defaultCollapsed === true;
713
+
714
+ parent.__treeCollapsed = isCollapsed;
715
+ parent.__treeChildCount = children.length;
716
+
717
+ grouped.push(parent);
718
+
719
+ if (!isCollapsed) {
720
+ grouped.push(...children);
721
+ }
722
+ } else {
723
+ grouped.push(parent, ...children);
724
+ }
693
725
  } else {
694
726
  grouped.push(row);
695
727
  }
@@ -723,7 +755,7 @@ const DataGrid = forwardRef(
723
755
  count: res.count,
724
756
  }));
725
757
  },
726
- [ajaxSetting, dSearch, collapsedGroups]
758
+ [ajaxSetting, dSearch, collapsedGroups, collapsedTreeGroups]
727
759
  );
728
760
  const [filterDataSource, setFilterDataSource] = useState([]);
729
761
  const [filterValue, setFilterValue] = useState([]);
@@ -1612,12 +1644,60 @@ const DataGrid = forwardRef(
1612
1644
  }, 50); // Increased delay to ensure proper rendering after reload
1613
1645
  };
1614
1646
 
1647
+ /**
1648
+ * Flip a treeGroupBy block open/shut. The state change re-creates
1649
+ * dataSource, which reloads and re-runs the tree transform with the
1650
+ * new collapse state.
1651
+ */
1652
+ const toggleTreeGroup = (groupValue) => {
1653
+ const defaultCollapsed =
1654
+ ajaxSetting?.treeGroupBy?.defaultCollapsed === true;
1655
+
1656
+ setCollapsedTreeGroups((prev) => ({
1657
+ ...prev,
1658
+ [groupValue]: !(prev[groupValue] ?? defaultCollapsed),
1659
+ }));
1660
+ };
1661
+
1662
+ /** Icon id (groupBySetting.icons[].id / treeGroupBy.icons[].id) → glyph */
1663
+ const getGroupIconComponent = (iconId) => {
1664
+ switch (iconId) {
1665
+ case 'clock':
1666
+ return Clock;
1667
+ case 'alarm':
1668
+ return AlarmClock;
1669
+ case 'check':
1670
+ return Check;
1671
+ case 'checkAll':
1672
+ return CheckCheck;
1673
+ case 'plus':
1674
+ return Plus;
1675
+ case 'edit':
1676
+ return Edit;
1677
+ case 'envelope':
1678
+ return Mail;
1679
+ case 'file':
1680
+ return File;
1681
+ case 'image':
1682
+ return Image;
1683
+ case 'copy':
1684
+ return Copy;
1685
+ case 'cloudUpload':
1686
+ return Upload;
1687
+ case 'cloudDownload':
1688
+ return DownloadIcon;
1689
+ default:
1690
+ return Clock; // Default fallback
1691
+ }
1692
+ };
1693
+
1615
1694
  const handleGroupAction = async (groupValue, iconConfig) => {
1616
1695
  // Get all rows for this group
1617
1696
  const groupRows = getGroupRows(groupValue);
1618
1697
 
1619
- // Skip confirmation dialog for edit actions - open modal directly
1620
- if (iconConfig.id === 'edit' && iconConfig.formModal) {
1698
+ // Skip confirmation dialog for modal actions - the modal itself is
1699
+ // the confirmation step
1700
+ if (iconConfig.formModal) {
1621
1701
  executeGroupAction(groupValue, iconConfig);
1622
1702
  return;
1623
1703
  }
@@ -1645,56 +1725,97 @@ const DataGrid = forwardRef(
1645
1725
  });
1646
1726
  };
1647
1727
 
1728
+ // dataRef holds the real rows only (the treeGroupBy transform runs
1729
+ // after it is set), so this resolves a group the same way whether the
1730
+ // view groups via the grid's groupBy or via treeGroupBy.
1648
1731
  const getGroupRows = (groupValue) => {
1649
- if (!dataRef.current || !ajaxSetting?.groupBy) {
1732
+ const groupField =
1733
+ ajaxSetting?.groupBy?.[0] || ajaxSetting?.treeGroupBy?.key;
1734
+
1735
+ if (!dataRef.current || !groupField) {
1650
1736
  return [];
1651
1737
  }
1652
1738
 
1653
- const groupField = ajaxSetting.groupBy[0];
1654
1739
  return dataRef.current.filter(
1655
1740
  (row) => row[groupField] === groupValue
1656
1741
  );
1657
1742
  };
1658
1743
 
1744
+ /**
1745
+ * The show-condition grammar shared by group action icons and row
1746
+ * action icons:
1747
+ * {id} field must be set (non-empty)
1748
+ * {id, value} strict match ({value: null} = empty)
1749
+ * {id, operator: 'empty'} field must be empty
1750
+ * {id, operator: 'notEmpty'} field must be set
1751
+ */
1752
+ const matchesRowCondition = (row, condition) => {
1753
+ if (!condition || !condition.id) {
1754
+ return true;
1755
+ }
1756
+
1757
+ const { id, value, operator } = condition;
1758
+ const rowValue = row?.[id];
1759
+ const isEmpty =
1760
+ rowValue === null || rowValue === undefined || rowValue === '';
1761
+
1762
+ if (operator === 'empty') {
1763
+ return isEmpty;
1764
+ }
1765
+
1766
+ if (operator === 'notEmpty') {
1767
+ return !isEmpty;
1768
+ }
1769
+
1770
+ // No value prop → field must be truthy (set/non-empty)
1771
+ if (!Object.prototype.hasOwnProperty.call(condition, 'value')) {
1772
+ return !isEmpty;
1773
+ }
1774
+
1775
+ // Handle null value condition
1776
+ if (value === null) {
1777
+ return isEmpty;
1778
+ }
1779
+
1780
+ // Handle other value conditions
1781
+ return rowValue === value;
1782
+ };
1783
+
1784
+ /** True when the signed-in user holds any of the listed role names */
1785
+ const userHasAnyRole = (roleNames) =>
1786
+ Array.isArray(roleNames) &&
1787
+ roleNames.some((requiredRole) =>
1788
+ userProfile?.roles?.some(
1789
+ (userRole) => userRole.name === requiredRole
1790
+ )
1791
+ );
1792
+
1659
1793
  const shouldShowGroupAction = (iconConfig, groupValue) => {
1794
+ // `showAll` is the every-row form of `show`: the icon appears only
1795
+ // when the condition holds for the whole group (e.g. close a tag
1796
+ // out only once every header on it is finished)
1797
+ const requireAll = Array.isArray(iconConfig.showAll);
1798
+ const conditions = requireAll ? iconConfig.showAll : iconConfig.show;
1799
+
1660
1800
  // If no show conditions, always show the icon
1661
- if (!iconConfig.show || !Array.isArray(iconConfig.show)) {
1801
+ if (!conditions || !Array.isArray(conditions)) {
1662
1802
  return true;
1663
1803
  }
1664
1804
 
1665
1805
  // Get all rows for this group
1666
1806
  const groupRows = getGroupRows(groupValue);
1667
1807
 
1668
- // Check if all show conditions are met
1669
- return iconConfig.show.every((condition) => {
1670
- const { id, value } = condition;
1671
- const hasValueProp = condition.hasOwnProperty('value');
1672
-
1673
- // Check if any row in the group matches the condition
1674
- return groupRows.some((row) => {
1675
- const rowValue = row[id];
1676
-
1677
- // No value prop → field must be truthy (set/non-empty)
1678
- if (!hasValueProp) {
1679
- return (
1680
- rowValue !== null &&
1681
- rowValue !== undefined &&
1682
- rowValue !== ''
1683
- );
1684
- }
1808
+ if (requireAll && groupRows.length === 0) {
1809
+ return false;
1810
+ }
1685
1811
 
1686
- // Handle null value condition
1687
- if (value === null) {
1688
- return (
1689
- rowValue === null ||
1690
- rowValue === undefined ||
1691
- rowValue === ''
1692
- );
1693
- }
1812
+ // Check if all show conditions are met
1813
+ return conditions.every((condition) => {
1814
+ const matches = (row) => matchesRowCondition(row, condition);
1694
1815
 
1695
- // Handle other value conditions
1696
- return rowValue === value;
1697
- });
1816
+ return requireAll
1817
+ ? groupRows.every(matches)
1818
+ : groupRows.some(matches);
1698
1819
  });
1699
1820
  };
1700
1821
 
@@ -1707,18 +1828,40 @@ const DataGrid = forwardRef(
1707
1828
  groupLabel: groupValue,
1708
1829
  };
1709
1830
 
1831
+ // `dataFromRow` {payloadKey: rowField} carries an id the
1832
+ // endpoint needs but the group label does not hold (e.g.
1833
+ // split_group_id behind a tag). Every member row of a
1834
+ // group shares the value, so the first one answers.
1835
+ if (iconConfig.fetch.dataFromRow) {
1836
+ const sourceRow = getGroupRows(groupValue)[0];
1837
+
1838
+ Object.entries(iconConfig.fetch.dataFromRow).forEach(
1839
+ ([payloadKey, rowField]) => {
1840
+ payload[payloadKey] = sourceRow?.[rowField];
1841
+ }
1842
+ );
1843
+ }
1844
+
1710
1845
  const result = await CustomFetch(
1711
1846
  iconConfig.fetch.url,
1712
1847
  iconConfig.fetch.method || 'POST',
1713
1848
  payload
1714
1849
  );
1715
1850
 
1716
- if (result.error) {
1717
- throw new Error(result.error);
1851
+ // These endpoints answer 200 with an `error` string rather
1852
+ // than an HTTP error, so the body is what decides
1853
+ const responseError = result?.data?.error || result?.error;
1854
+
1855
+ if (responseError) {
1856
+ // CustomFetch has already toasted the message
1857
+ return;
1718
1858
  }
1719
1859
 
1720
1860
  toast.success(
1721
- `${iconConfig.label} - Group: ${groupValue} completed successfully`
1861
+ iconConfig.fetch.messageFromResponse &&
1862
+ result?.data?.message
1863
+ ? result.data.message
1864
+ : `${iconConfig.label} - Group: ${groupValue} completed successfully`
1722
1865
  );
1723
1866
 
1724
1867
  // Reload grid data if needed
@@ -1738,6 +1881,23 @@ const DataGrid = forwardRef(
1738
1881
  `${iconConfig.label} - Group: ${groupValue} failed: ${error.message}`
1739
1882
  );
1740
1883
  }
1884
+ } else if (iconConfig.formModal) {
1885
+ // Open the group form modal. Any icon may carry a formModal,
1886
+ // not just 'edit' — the config decides what the modal does.
1887
+ const groupRows = getGroupRows(groupValue);
1888
+ const groupRowIds = groupRows.map(
1889
+ (row) => row[form?.primaryKey || 'id']
1890
+ );
1891
+
1892
+ setBulkEditMode(true);
1893
+ setBulkEditGroupData({
1894
+ groupValue: groupValue,
1895
+ groupRows: groupRows,
1896
+ groupRowIds: groupRowIds,
1897
+ iconConfig: iconConfig,
1898
+ });
1899
+
1900
+ modalOpen('update', null);
1741
1901
  } else {
1742
1902
  // Fallback to existing switch logic if no fetch config
1743
1903
  // Skip toast notification for edit actions
@@ -1762,30 +1922,7 @@ const DataGrid = forwardRef(
1762
1922
  );
1763
1923
  break;
1764
1924
  case 'edit':
1765
- // Handle bulk edit action
1766
- if (iconConfig.formModal) {
1767
- // Get all rows in the group for bulk editing
1768
- const groupRows = getGroupRows(groupValue);
1769
- const groupRowIds = groupRows.map(
1770
- (row) => row[form?.primaryKey || 'id']
1771
- );
1772
-
1773
- // Set bulk edit data
1774
- setBulkEditMode(true);
1775
- setBulkEditGroupData({
1776
- groupValue: groupValue,
1777
- groupRows: groupRows,
1778
- groupRowIds: groupRowIds,
1779
- iconConfig: iconConfig,
1780
- });
1781
-
1782
- // Open modal for bulk editing
1783
- modalOpen('update', null);
1784
- } else {
1785
- debugLog(
1786
- `Bulk edit action for group: ${groupValue}`
1787
- );
1788
- }
1925
+ debugLog(`Bulk edit action for group: ${groupValue}`);
1789
1926
  break;
1790
1927
  case 'envelope':
1791
1928
  debugLog(
@@ -1801,6 +1938,175 @@ const DataGrid = forwardRef(
1801
1938
  }
1802
1939
  };
1803
1940
 
1941
+ /**
1942
+ * An `action` column's `settings` entry is either the id of a shared
1943
+ * setting (rendered by renderSetting) or an inline row action config
1944
+ * declared on the column itself.
1945
+ */
1946
+ const isRowActionConfig = (entry) =>
1947
+ entry !== null && typeof entry === 'object';
1948
+
1949
+ /** Lucide icons an inline row action may name via `icon` */
1950
+ const rowActionIcons = {
1951
+ check: Check,
1952
+ copy: Copy,
1953
+ delete: Trash2,
1954
+ edit: Edit,
1955
+ envelope: Mail,
1956
+ plus: Plus,
1957
+ trash: Trash2,
1958
+ undo: RotateCcw,
1959
+ };
1960
+
1961
+ /**
1962
+ * Row action visibility, in three parts:
1963
+ * - `show` must hold for the row (the hard rule: what the action is
1964
+ * even about, e.g. only split rows carry a header to remove)
1965
+ * - `removableWhen`, when present, must hold as well (the soft rule:
1966
+ * the state in which anyone may do it)
1967
+ * - unless the user holds one of `overrideRoles`, which bypasses the
1968
+ * soft rule only
1969
+ *
1970
+ * Synthetic tree parent rows are group summaries and never carry row
1971
+ * actions, and a row missing a field simply fails its condition.
1972
+ */
1973
+ const shouldShowRowAction = (config, row) => {
1974
+ if (!row || row.__treeParent) {
1975
+ return false;
1976
+ }
1977
+
1978
+ if (
1979
+ Array.isArray(config.show) &&
1980
+ !config.show.every((condition) =>
1981
+ matchesRowCondition(row, condition)
1982
+ )
1983
+ ) {
1984
+ return false;
1985
+ }
1986
+
1987
+ if (
1988
+ Array.isArray(config.removableWhen) &&
1989
+ !config.removableWhen.every((condition) =>
1990
+ matchesRowCondition(row, condition)
1991
+ )
1992
+ ) {
1993
+ return userHasAnyRole(config.overrideRoles);
1994
+ }
1995
+
1996
+ return true;
1997
+ };
1998
+
1999
+ /** Fill {field} placeholders in a config string from the row */
2000
+ const interpolateRowText = (text, row) =>
2001
+ String(text ?? '').replace(/{(\w+)}/g, (match, field) => {
2002
+ const value = row?.[field];
2003
+
2004
+ return value === null || value === undefined
2005
+ ? ''
2006
+ : String(value);
2007
+ });
2008
+
2009
+ const executeRowAction = async (config, row) => {
2010
+ const fetchConfig = config.fetch;
2011
+
2012
+ if (!fetchConfig?.url) {
2013
+ console.warn(
2014
+ `Row action "${config.id}" has no fetch configuration`
2015
+ );
2016
+
2017
+ return;
2018
+ }
2019
+
2020
+ // `pathKey` names the row field that completes a RESTful url
2021
+ // (/ajax/jobGoods + row.id)
2022
+ const url = fetchConfig.pathKey
2023
+ ? `${fetchConfig.url}/${row?.[fetchConfig.pathKey]}`
2024
+ : fetchConfig.url;
2025
+
2026
+ try {
2027
+ const result = await CustomFetch(
2028
+ url,
2029
+ fetchConfig.method || 'POST',
2030
+ { ...fetchConfig.data }
2031
+ );
2032
+
2033
+ // These endpoints answer 200 with an `error` string rather
2034
+ // than an HTTP error, so the body is what decides. CustomFetch
2035
+ // has already toasted the message.
2036
+ if (result?.data?.error || result?.error) {
2037
+ return;
2038
+ }
2039
+
2040
+ toast.success(
2041
+ fetchConfig.messageFromResponse && result?.data?.message
2042
+ ? result.data.message
2043
+ : `${config.label || 'Action'} completed successfully`
2044
+ );
2045
+
2046
+ handleReload();
2047
+ } catch (error) {
2048
+ console.error('Row action fetch error:', error);
2049
+ toast.error(
2050
+ `${config.label || 'Action'} failed: ${error.message}`
2051
+ );
2052
+ }
2053
+ };
2054
+
2055
+ const handleRowAction = (config, row) => {
2056
+ if (!config.confirm) {
2057
+ executeRowAction(config, row);
2058
+
2059
+ return;
2060
+ }
2061
+
2062
+ confirmDialog({
2063
+ title: config.label || 'Confirm Action',
2064
+ message: interpolateRowText(config.confirm, row),
2065
+ buttons: [
2066
+ {
2067
+ label: 'Yes',
2068
+ onClick: () => {
2069
+ executeRowAction(config, row);
2070
+ },
2071
+ },
2072
+ {
2073
+ label: 'No',
2074
+ onClick: () => {
2075
+ // User cancelled
2076
+ },
2077
+ },
2078
+ ],
2079
+ });
2080
+ };
2081
+
2082
+ const renderRowAction = (config, row) => {
2083
+ const IconComponent = rowActionIcons[config.icon];
2084
+
2085
+ if (!IconComponent || !shouldShowRowAction(config, row)) {
2086
+ return null;
2087
+ }
2088
+
2089
+ return (
2090
+ <span
2091
+ key={`row-action-${config.id}`}
2092
+ onClick={(e) => {
2093
+ e.preventDefault();
2094
+ e.stopPropagation();
2095
+ handleRowAction(config, row);
2096
+ }}
2097
+ >
2098
+ <IconComponent
2099
+ data-tooltip-id="system-tooltip"
2100
+ data-tooltip-content={config.label || ''}
2101
+ strokeWidth={2}
2102
+ size={18}
2103
+ className={styles.tdaction}
2104
+ style={config.colour ? { color: config.colour } : {}}
2105
+ />
2106
+ </span>
2107
+ );
2108
+ };
2109
+
1804
2110
  // Touch handlers for tooltips
1805
2111
  const handleTouchStart = (e, buttonId, label) => {
1806
2112
  // Clear any existing timer
@@ -2590,13 +2896,7 @@ const DataGrid = forwardRef(
2590
2896
  // Check role-based permissions if roles are specified
2591
2897
  if (s.roles && Array.isArray(s.roles) && s.roles.length > 0) {
2592
2898
  // Check if user has any of the required roles
2593
- const hasRequiredRole = s.roles.some((requiredRole) =>
2594
- userProfile?.roles?.some(
2595
- (userRole) => userRole.name === requiredRole
2596
- )
2597
- );
2598
-
2599
- if (!hasRequiredRole) {
2899
+ if (!userHasAnyRole(s.roles)) {
2600
2900
  allow = false;
2601
2901
  }
2602
2902
  }
@@ -3067,6 +3367,8 @@ const DataGrid = forwardRef(
3067
3367
 
3068
3368
  // Settings that an 'action' column renders inline render there
3069
3369
  // instead of the trailing Action column (avoids duplication).
3370
+ // (Inline row action configs are declared on the column
3371
+ // itself, so they never name a shared setting.)
3070
3372
  const actionColumnSettingIds = new Set(
3071
3373
  (columns || [])
3072
3374
  .filter(
@@ -3075,6 +3377,7 @@ const DataGrid = forwardRef(
3075
3377
  Array.isArray(c.settings)
3076
3378
  )
3077
3379
  .flatMap((c) => c.settings)
3380
+ .filter((entry) => !isRowActionConfig(entry))
3078
3381
  );
3079
3382
 
3080
3383
  // Width to fit N action icons: tablet-mode icons have a larger
@@ -3238,15 +3541,26 @@ const DataGrid = forwardRef(
3238
3541
 
3239
3542
  switch (column.type) {
3240
3543
  case 'action': {
3241
- const actionSettings = (column.settings || [])
3544
+ // A string entry names a shared setting; an
3545
+ // object entry is an inline row action the
3546
+ // column declares itself
3547
+ const entries = column.settings || [];
3548
+ const actionSettings = entries
3549
+ .filter(
3550
+ (entry) => !isRowActionConfig(entry)
3551
+ )
3242
3552
  .map((id) =>
3243
3553
  memoizedSettings.find(
3244
3554
  (s) => s.id === id
3245
3555
  )
3246
3556
  )
3247
3557
  .filter(Boolean);
3558
+ const rowActions =
3559
+ entries.filter(isRowActionConfig);
3560
+ // Per-row conditions may hide row actions at
3561
+ // runtime, but the column must fit the maximum
3248
3562
  const width = actionColumnWidthFor(
3249
- actionSettings.length
3563
+ actionSettings.length + rowActions.length
3250
3564
  );
3251
3565
  return {
3252
3566
  ...commonProps,
@@ -3264,6 +3578,9 @@ const DataGrid = forwardRef(
3264
3578
  {actionSettings.map((setting) =>
3265
3579
  renderSetting(setting, data)
3266
3580
  )}
3581
+ {rowActions.map((rowAction) =>
3582
+ renderRowAction(rowAction, data)
3583
+ )}
3267
3584
  </div>
3268
3585
  ),
3269
3586
  };
@@ -3641,8 +3958,188 @@ const DataGrid = forwardRef(
3641
3958
  };
3642
3959
  };
3643
3960
 
3961
+ // The tree parent's display column doubles as the group's
3962
+ // header line: expand/collapse plus any treeGroupBy.icons
3963
+ // whose show/showAll conditions the group's rows satisfy.
3964
+ const decorateTreeParentActions = (
3965
+ gridColumn,
3966
+ columnDef
3967
+ ) => {
3968
+ const tree = ajaxSetting?.treeGroupBy;
3969
+ const columnId = Array.isArray(columnDef?.id)
3970
+ ? columnDef.id.join('-')
3971
+ : columnDef?.id;
3972
+
3973
+ if (
3974
+ !tree?.key ||
3975
+ !tree.column ||
3976
+ tree.column !== columnId ||
3977
+ !(tree.collapsible || tree.icons?.length)
3978
+ ) {
3979
+ return gridColumn;
3980
+ }
3981
+
3982
+ const originalRender = gridColumn.render;
3983
+
3984
+ return {
3985
+ ...gridColumn,
3986
+ render: (arg) => {
3987
+ const data = arg?.data;
3988
+
3989
+ if (!data?.__treeParent) {
3990
+ return originalRender
3991
+ ? originalRender(arg)
3992
+ : null;
3993
+ }
3994
+
3995
+ const groupValue = data[tree.key];
3996
+ const collapsed = data.__treeCollapsed === true;
3997
+
3998
+ return (
3999
+ <div
4000
+ className={styles.treeParentHeader}
4001
+ onClick={(e) => e.stopPropagation()}
4002
+ >
4003
+ {tree.collapsible && (
4004
+ <button
4005
+ className={`group-expand-btn ${styles.groupExpandBtn}`}
4006
+ onClick={(e) => {
4007
+ e.stopPropagation();
4008
+ toggleTreeGroup(groupValue);
4009
+ }}
4010
+ data-tooltip-id="system-tooltip"
4011
+ data-tooltip-content={
4012
+ collapsed
4013
+ ? 'Expand group'
4014
+ : 'Collapse group'
4015
+ }
4016
+ >
4017
+ {collapsed ? '+' : '−'}
4018
+ </button>
4019
+ )}
4020
+ <strong
4021
+ className={styles.treeParentLabel}
4022
+ >
4023
+ {groupValue}
4024
+ {data.__treeChildCount
4025
+ ? ` (${data.__treeChildCount})`
4026
+ : ''}
4027
+ </strong>
4028
+ {(tree.icons || []).map(
4029
+ (iconConfig, index) => {
4030
+ if (
4031
+ !shouldShowGroupAction(
4032
+ iconConfig,
4033
+ groupValue
4034
+ )
4035
+ ) {
4036
+ return null;
4037
+ }
4038
+
4039
+ const IconComponent =
4040
+ getGroupIconComponent(
4041
+ iconConfig.id
4042
+ );
4043
+
4044
+ return (
4045
+ <button
4046
+ key={`tree-action-${index}`}
4047
+ className={`group-action-btn ${
4048
+ styles.groupActionBtn
4049
+ } ${
4050
+ styles[
4051
+ iconConfig.colour ||
4052
+ 'success'
4053
+ ] || ''
4054
+ }`}
4055
+ onClick={(e) => {
4056
+ e.stopPropagation();
4057
+ handleGroupAction(
4058
+ groupValue,
4059
+ iconConfig
4060
+ );
4061
+ }}
4062
+ data-tooltip-id="system-tooltip"
4063
+ data-tooltip-content={
4064
+ iconConfig.label
4065
+ }
4066
+ >
4067
+ <IconComponent
4068
+ strokeWidth={2}
4069
+ size={14}
4070
+ />
4071
+ </button>
4072
+ );
4073
+ }
4074
+ )}
4075
+ </div>
4076
+ );
4077
+ },
4078
+ };
4079
+ };
4080
+
4081
+ // Any column may carry `rowActions` (same config objects
4082
+ // as an 'action' column's settings): the icons render
4083
+ // after the cell's normal content. Useful for cells that
4084
+ // are blank on tree-child rows (e.g. a parentFields-
4085
+ // lifted column) so the action reuses the empty space.
4086
+ const decorateColumnRowActions = (gridColumn, columnDef) => {
4087
+ if (!Array.isArray(columnDef?.rowActions)) {
4088
+ return gridColumn;
4089
+ }
4090
+
4091
+ const originalRender = gridColumn.render;
4092
+
4093
+ return {
4094
+ ...gridColumn,
4095
+ render: (arg) => {
4096
+ const base = originalRender
4097
+ ? originalRender(arg)
4098
+ : null;
4099
+ const row = arg?.data;
4100
+
4101
+ if (!row || row.__treeParent) {
4102
+ return base;
4103
+ }
4104
+
4105
+ const icons = columnDef.rowActions
4106
+ .map((config) =>
4107
+ renderRowAction(config, row)
4108
+ )
4109
+ .filter(Boolean);
4110
+
4111
+ if (!icons.length) {
4112
+ return base;
4113
+ }
4114
+
4115
+ return (
4116
+ <div
4117
+ style={{
4118
+ display: 'flex',
4119
+ alignItems: 'center',
4120
+ gap: '4px',
4121
+ }}
4122
+ onClick={(e) => e.stopPropagation()}
4123
+ >
4124
+ {base}
4125
+ {icons}
4126
+ </div>
4127
+ );
4128
+ },
4129
+ };
4130
+ };
4131
+
3644
4132
  const newColumns = columns.map((column) =>
3645
- guardTreeParentRender(renderColumn(column), column)
4133
+ decorateColumnRowActions(
4134
+ decorateTreeParentActions(
4135
+ guardTreeParentRender(
4136
+ renderColumn(column),
4137
+ column
4138
+ ),
4139
+ column
4140
+ ),
4141
+ column
4142
+ )
3646
4143
  );
3647
4144
 
3648
4145
  // Count settings that could render in the trailing Action
@@ -4393,37 +4890,6 @@ const DataGrid = forwardRef(
4393
4890
  iconConfig,
4394
4891
  index
4395
4892
  ) => {
4396
- // Get the icon component based on the id
4397
- const getIconComponent =
4398
- (iconId) => {
4399
- switch (
4400
- iconId
4401
- ) {
4402
- case 'clock':
4403
- return Clock;
4404
- case 'alarm':
4405
- return AlarmClock;
4406
- case 'check':
4407
- return Check;
4408
- case 'edit':
4409
- return Edit;
4410
- case 'envelope':
4411
- return Mail;
4412
- case 'file':
4413
- return File;
4414
- case 'image':
4415
- return Image;
4416
- case 'copy':
4417
- return Copy;
4418
- case 'cloudUpload':
4419
- return Upload;
4420
- case 'cloudDownload':
4421
- return DownloadIcon;
4422
- default:
4423
- return Clock; // Default fallback
4424
- }
4425
- };
4426
-
4427
4893
  // Check if this icon should be shown based on show conditions
4428
4894
  if (
4429
4895
  !shouldShowGroupAction(
@@ -4435,7 +4901,7 @@ const DataGrid = forwardRef(
4435
4901
  }
4436
4902
 
4437
4903
  const IconComponent =
4438
- getIconComponent(
4904
+ getGroupIconComponent(
4439
4905
  iconConfig.id
4440
4906
  );
4441
4907
 
@@ -1202,12 +1202,28 @@ function Form({
1202
1202
  let validation = '';
1203
1203
 
1204
1204
  if (['create', 'update'].includes(formType)) {
1205
+ // A group modal renders only the handful of fields it
1206
+ // names, so the rest of the form's required fields are
1207
+ // not on screen for the user to fill — validating them
1208
+ // would make the modal impossible to save
1209
+ const modalFieldIds =
1210
+ bulkEditMode &&
1211
+ bulkEditGroupData?.iconConfig?.formModal?.fields
1212
+ ? new Set(
1213
+ bulkEditGroupData.iconConfig.formModal.fields
1214
+ )
1215
+ : null;
1216
+
1205
1217
  fields.forEach((item) => {
1206
1218
  // Skip createOnly fields if formType is 'update'
1207
1219
  if (formType === 'update' && item.createOnly) {
1208
1220
  return;
1209
1221
  }
1210
1222
 
1223
+ if (modalFieldIds && !modalFieldIds.has(item.id)) {
1224
+ return;
1225
+ }
1226
+
1211
1227
  // Check if the item is required
1212
1228
  if (item.required) {
1213
1229
  // Find the corresponding Field component to check for auto values
@@ -1573,6 +1589,20 @@ function Form({
1573
1589
  bulkEditGroupData.iconConfig.formModal;
1574
1590
  url = formModalConfig.url || url;
1575
1591
  method = formModalConfig.method || 'POST';
1592
+
1593
+ // `anchorRow` modals act on the group through
1594
+ // one of its real records (the endpoint reads
1595
+ // the rest of the group off that row), so the
1596
+ // id goes in the path like an ordinary update
1597
+ if (formModalConfig.anchorRow) {
1598
+ const anchorRow =
1599
+ bulkEditGroupData.groupRows?.[0];
1600
+ url += `/${
1601
+ anchorRow?.[
1602
+ formSettings.primaryKey || 'id'
1603
+ ]
1604
+ }`;
1605
+ }
1576
1606
  } else {
1577
1607
  switch (formType) {
1578
1608
  case 'create':
@@ -1853,16 +1883,39 @@ function Form({
1853
1883
 
1854
1884
  // Add bulk edit data to payload if in bulk edit mode
1855
1885
  if (bulkEditMode && bulkEditGroupData) {
1856
- payload = {
1857
- ...payload,
1858
- bulkEdit: true,
1859
- groupValue: bulkEditGroupData.groupValue,
1860
- groupKey:
1861
- bulkEditGroupData.iconConfig.formModal
1862
- ?.groupKey || ajaxSetting?.groupBy?.[0],
1863
- rowIds: bulkEditGroupData.groupRowIds,
1864
- ...bulkEditGroupData.iconConfig.formModal?.data,
1865
- };
1886
+ const formModalConfig =
1887
+ bulkEditGroupData.iconConfig.formModal;
1888
+
1889
+ if (formModalConfig?.anchorRow) {
1890
+ // A plain record update: send only what the
1891
+ // modal asked for, or the untouched blanks of
1892
+ // every other form field would wipe the row
1893
+ const allowed = new Set([
1894
+ ...(formModalConfig.fields || []),
1895
+ ...Object.keys(formModalConfig.data || {}),
1896
+ ]);
1897
+
1898
+ payload = Object.keys(payload).reduce(
1899
+ (acc, key) => {
1900
+ if (allowed.has(key)) {
1901
+ acc[key] = payload[key];
1902
+ }
1903
+ return acc;
1904
+ },
1905
+ { ...formModalConfig.data }
1906
+ );
1907
+ } else {
1908
+ payload = {
1909
+ ...payload,
1910
+ bulkEdit: true,
1911
+ groupValue: bulkEditGroupData.groupValue,
1912
+ groupKey:
1913
+ formModalConfig?.groupKey ||
1914
+ ajaxSetting?.groupBy?.[0],
1915
+ rowIds: bulkEditGroupData.groupRowIds,
1916
+ ...formModalConfig?.data,
1917
+ };
1918
+ }
1866
1919
  }
1867
1920
 
1868
1921
  const res = await CustomFetch(url, method, payload);
@@ -2416,13 +2469,83 @@ function Form({
2416
2469
  toast.error('Failed to fetch data:', error);
2417
2470
  }
2418
2471
  } else {
2472
+ const formModalConfig =
2473
+ bulkEditGroupData?.iconConfig?.formModal || null;
2474
+
2475
+ // Anchor-row modals submit against one real record, and the
2476
+ // endpoint reads that record's current values back out of the
2477
+ // payload (adding a header means "these plus this new one"), so
2478
+ // seed from the anchor rather than from what the group shares.
2479
+ // `seedFields` adds rows values a field needs but does not submit
2480
+ // — e.g. the job id a conflict check excludes itself by.
2481
+ if (
2482
+ formModalConfig?.anchorRow &&
2483
+ bulkEditGroupData?.groupRows?.length
2484
+ ) {
2485
+ const anchorRow = bulkEditGroupData.groupRows[0];
2486
+ const seedIds = [
2487
+ ...(formModalConfig.fields || []),
2488
+ ...(formModalConfig.seedFields || []),
2489
+ ];
2490
+
2491
+ seedIds.forEach((id) => {
2492
+ const field = formSettings.fields.find((f) => f.id === id);
2493
+ const relationKey = field?.relation?.[0];
2494
+ const relationValue = relationKey
2495
+ ? anchorRow[relationKey]
2496
+ : undefined;
2497
+
2498
+ if (Array.isArray(relationValue)) {
2499
+ // The modal represents the whole group, but each
2500
+ // member row may hold only its own slice of the
2501
+ // relation (a split member carries one header), so
2502
+ // seed the union across every row, deduped. The
2503
+ // grow endpoint ignores ids a member already holds.
2504
+ const keyOf = (entry) =>
2505
+ entry[field.relation_from || 'id'];
2506
+ const seen = new Set();
2507
+
2508
+ _formData[id] = bulkEditGroupData.groupRows
2509
+ .flatMap((row) =>
2510
+ Array.isArray(row[relationKey])
2511
+ ? row[relationKey]
2512
+ : []
2513
+ )
2514
+ .filter((entry) => {
2515
+ if (seen.has(keyOf(entry))) {
2516
+ return false;
2517
+ }
2518
+ seen.add(keyOf(entry));
2519
+ return true;
2520
+ })
2521
+ .map((entry) => ({
2522
+ value: keyOf(entry),
2523
+ label:
2524
+ entry[field.relationName] ??
2525
+ entry.label ??
2526
+ entry.name,
2527
+ // Existing memberships are locked in the
2528
+ // modal — it can only add, never take away
2529
+ isFixed: true,
2530
+ }));
2531
+ return;
2532
+ }
2533
+
2534
+ if (anchorRow[id] !== undefined) {
2535
+ _formData[id] = anchorRow[id];
2536
+ }
2537
+ });
2538
+
2539
+ setFormData(_formData);
2540
+ return;
2541
+ }
2542
+
2419
2543
  // Bulk edit opens without a record fetch (columnId is null), so
2420
2544
  // seed each field with the value the group rows already share —
2421
2545
  // otherwise saved values (e.g. an assigned user) render blank and
2422
2546
  // required fields force a re-pick on every open.
2423
2547
  if (bulkEditMode && bulkEditGroupData?.groupRows?.length) {
2424
- const modalFields =
2425
- bulkEditGroupData.iconConfig?.formModal?.fields || [];
2548
+ const modalFields = formModalConfig?.fields || [];
2426
2549
 
2427
2550
  formSettings.fields.forEach((item) => {
2428
2551
  const { id, type, parent } = item;
@@ -3418,6 +3541,20 @@ function Form({
3418
3541
  formData
3419
3542
  )
3420
3543
  : false;
3544
+ // A group modal whose save means one
3545
+ // narrow thing (e.g. "add a header")
3546
+ // must not also offer the stage-
3547
+ // completing saveAnother, which would
3548
+ // fire against every row in the group
3549
+ const showSaveAnother =
3550
+ formSettings.update?.hasOwnProperty(
3551
+ 'saveAnother'
3552
+ ) &&
3553
+ !(
3554
+ bulkEditMode &&
3555
+ bulkEditGroupData?.iconConfig
3556
+ ?.formModal?.hideSaveAnother
3557
+ );
3421
3558
 
3422
3559
  return (
3423
3560
  <>
@@ -3431,9 +3568,7 @@ function Form({
3431
3568
  >
3432
3569
  Save & Close
3433
3570
  </button>
3434
- {formSettings.update?.hasOwnProperty(
3435
- 'saveAnother'
3436
- ) && (
3571
+ {showSaveAnother && (
3437
3572
  <button
3438
3573
  className={
3439
3574
  styles.btn
@@ -64,6 +64,13 @@ const CustomMultiValue = (props) => {
64
64
 
65
65
  // Custom MultiValueRemove component with proper removal handling
66
66
  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
+ if (props.data?.isFixed) {
71
+ return null;
72
+ }
73
+
67
74
  const handleRemove = (e) => {
68
75
  e.stopPropagation();
69
76
 
@@ -334,7 +341,27 @@ function MultiSelect({
334
341
  action: action
335
342
  });
336
343
  }
337
-
344
+
345
+ // Values seeded with isFixed represent memberships this
346
+ // field is not allowed to take away (e.g. a tag's existing
347
+ // headers in an add-only group modal): ignore their removal
348
+ // and survive a clear-all.
349
+ if (
350
+ ['remove-value', 'pop-value'].includes(action.action) &&
351
+ action.removedValue?.isFixed
352
+ ) {
353
+ return;
354
+ }
355
+
356
+ if (action.action === 'clear') {
357
+ const fixed = selectValue.filter((v) => v?.isFixed);
358
+
359
+ if (fixed.length) {
360
+ onChange(fixed, action, settings.id);
361
+ return;
362
+ }
363
+ }
364
+
338
365
  // Special handling for limit=1: keep only the most recent selection
339
366
  if (
340
367
  settings.limit === 1 &&
@@ -435,7 +462,8 @@ function MultiSelect({
435
462
  multiValueRemove: (base, state) => ({
436
463
  ...base,
437
464
  padding: '0 4px',
438
- display: 'flex',
465
+ // Fixed values cannot be deselected, so they get no ×
466
+ display: state.data?.isFixed ? 'none' : 'flex',
439
467
  alignItems: 'center',
440
468
  justifyContent: 'center',
441
469
  margin: '0',
@@ -357,7 +357,7 @@
357
357
 
358
358
  /* Add custom styles to override react-grid-gallery */
359
359
  :global {
360
- /* These styles will be applied globally */
360
+ // These styles will be applied globally
361
361
  .ReactGridGallery_tile {
362
362
  border-radius: 6px !important;
363
363
  overflow: hidden !important;
@@ -508,7 +508,7 @@
508
508
 
509
509
  /* Date field styling for uniform width */
510
510
  :global {
511
- /* Target date cells specifically */
511
+ // Target date cells specifically
512
512
  .InovuaReactDataGrid__cell[data-column-id*='date'],
513
513
  .InovuaReactDataGrid__cell[data-column-id*='Date'] {
514
514
  min-width: 120px !important;
@@ -516,7 +516,7 @@
516
516
  max-width: 120px !important;
517
517
  }
518
518
 
519
- /* Target datetime cells specifically */
519
+ // Target datetime cells specifically
520
520
  .InovuaReactDataGrid__cell[data-column-id*='datetime'],
521
521
  .InovuaReactDataGrid__cell[data-column-id*='Datetime'] {
522
522
  min-width: 160px !important;
@@ -524,7 +524,7 @@
524
524
  max-width: 160px !important;
525
525
  }
526
526
 
527
- /* Target daterange cells specifically */
527
+ // Target daterange cells specifically
528
528
  .InovuaReactDataGrid__cell[data-column-id*='daterange'] {
529
529
  min-width: 200px !important;
530
530
  width: 200px !important;
@@ -1048,6 +1048,24 @@
1048
1048
  margin-left: 4px;
1049
1049
  }
1050
1050
 
1051
+ // treeGroupBy parent rows: the group's controls live in an ordinary grid
1052
+ // cell, so keep the buttons on one line and let the label take what is left
1053
+ .treeParentHeader {
1054
+ display: flex;
1055
+ align-items: center;
1056
+ gap: 2px;
1057
+ width: 100%;
1058
+ overflow: hidden;
1059
+ }
1060
+
1061
+ .treeParentLabel {
1062
+ font-weight: 700;
1063
+ white-space: nowrap;
1064
+ overflow: hidden;
1065
+ text-overflow: ellipsis;
1066
+ margin-right: 6px;
1067
+ }
1068
+
1051
1069
  .groupValue {
1052
1070
  font-size: 0.85rem;
1053
1071
  color: var(--secondary-color, #1f2937);
@@ -91,14 +91,14 @@
91
91
  overflow-y: auto !important;
92
92
  }
93
93
 
94
- /* Control styles */
94
+ // Control styles
95
95
  .visns-select__control {
96
96
  min-height: 50px !important;
97
97
  height: auto !important; /* Allow height to grow with content */
98
98
  overflow: visible !important;
99
99
  }
100
100
 
101
- /* Value container styles */
101
+ // Value container styles
102
102
  .visns-select__value-container {
103
103
  padding: 2px 8px !important;
104
104
  flex-wrap: wrap !important;
@@ -106,7 +106,7 @@
106
106
  overflow-y: auto !important; /* Add scrolling when needed */
107
107
  }
108
108
 
109
- /* Indicator styles */
109
+ // Indicator styles
110
110
  .visns-select__indicators {
111
111
  display: flex !important;
112
112
  align-items: center !important;
@@ -130,7 +130,7 @@
130
130
  margin-left: 2px !important;
131
131
  }
132
132
 
133
- /* Multi-value styles */
133
+ // Multi-value styles
134
134
  .visns-select__multi-value {
135
135
  margin: 2px !important;
136
136
  padding: 2px 4px !important;
@@ -187,7 +187,7 @@
187
187
  transform: scale(0.95) !important;
188
188
  }
189
189
 
190
- /* Fix for modals */
190
+ // Fix for modals
191
191
  .popup-content {
192
192
  overflow: visible !important;
193
193
  }
@@ -196,7 +196,7 @@
196
196
  overflow: visible !important;
197
197
  }
198
198
 
199
- /* CSS class fixes for React Select */
199
+ // CSS class fixes for React Select
200
200
  .css-13cymwt-control,
201
201
  .css-t3ipsp-control {
202
202
  min-height: 50px !important;
@@ -204,7 +204,7 @@
204
204
  overflow: visible !important;
205
205
  }
206
206
 
207
- /* Option styling for description */
207
+ // Option styling for description
208
208
  .visns-select__option {
209
209
  padding: 8px 12px !important;
210
210
  }
@@ -216,7 +216,7 @@
216
216
  overflow-y: auto !important;
217
217
  }
218
218
 
219
- /* Indicators container */
219
+ // Indicators container
220
220
  .css-1hb7zxy-IndicatorsContainer {
221
221
  display: flex !important;
222
222
  align-items: center !important;
@@ -224,7 +224,7 @@
224
224
  height: auto !important;
225
225
  }
226
226
 
227
- /* Individual indicators */
227
+ // Individual indicators
228
228
  .css-1xc3v61-indicatorContainer,
229
229
  .css-15lsz6c-indicatorContainer {
230
230
  padding: 8px !important;
@@ -233,7 +233,7 @@
233
233
  align-self: stretch !important;
234
234
  }
235
235
 
236
- /* Separator */
236
+ // Separator
237
237
  .css-1u9des2-indicatorSeparator {
238
238
  align-self: stretch !important;
239
239
  margin: 4px 0 !important;
@@ -316,18 +316,22 @@ input[type]:not([type='search']):not([type='url']):not([type='hidden']):not(
316
316
  border-width: 1px;
317
317
  box-sizing: border-box;
318
318
  box-shadow: none !important;
319
+ }
319
320
 
320
- &--is-focused {
321
- border-color: var(--primary-color) !important;
322
- box-shadow: none !important;
323
- border-width: 1px !important;
324
- }
321
+ /* These were written as `&--is-focused` / `&--menu-is-open` nested inside the
322
+ rule above. That is Sass syntax — this is a plain .css file, and CSS nesting
323
+ cannot concatenate a suffix onto the parent selector, so both rules were
324
+ invalid and the focus/menu-open border colour never applied. */
325
+ .visns-select__control--is-focused {
326
+ border-color: var(--primary-color) !important;
327
+ box-shadow: none !important;
328
+ border-width: 1px !important;
329
+ }
325
330
 
326
- &--menu-is-open {
327
- border-color: var(--primary-color) !important;
328
- box-shadow: none !important;
329
- border-width: 1px !important;
330
- }
331
+ .visns-select__control--menu-is-open {
332
+ border-color: var(--primary-color) !important;
333
+ box-shadow: none !important;
334
+ border-width: 1px !important;
331
335
  }
332
336
 
333
337
  .visns-select__menu {