@visns-studio/visns-components 6.0.1 → 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.
@@ -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
@@ -2403,23 +2709,68 @@ const DataGrid = forwardRef(
2403
2709
  }
2404
2710
  }, '');
2405
2711
 
2406
- const iconData = config.icons.find(
2712
+ let iconData = config.icons.find(
2407
2713
  (icon) => icon.value === fieldValue
2408
2714
  );
2409
2715
 
2716
+ // An icon marked fallback: true matches any non-empty value
2717
+ // that no explicit entry matched (e.g. "any country other
2718
+ // than Australia" → international icon).
2719
+ if (
2720
+ !iconData &&
2721
+ fieldValue !== '' &&
2722
+ fieldValue != null &&
2723
+ fieldValue !== 0
2724
+ ) {
2725
+ iconData = config.icons.find(
2726
+ (icon) => icon.fallback === true
2727
+ );
2728
+ }
2729
+
2410
2730
  if (
2411
2731
  iconData &&
2412
2732
  (iconData.src || iconData.icon) &&
2413
2733
  iconData.alt &&
2414
2734
  iconData.tooltipContent
2415
2735
  ) {
2736
+ // tooltipFrom: path into the row data whose value (when
2737
+ // present) replaces the static tooltip — e.g. show the
2738
+ // actual country name on the international icon. Optional
2739
+ // tooltipPrefix is prepended to the resolved value.
2740
+ let tooltipContent = iconData.tooltipContent;
2741
+ if (Array.isArray(iconData.tooltipFrom)) {
2742
+ const resolved = iconData.tooltipFrom.reduce(
2743
+ (acc, id) => (acc == null ? acc : acc[id]),
2744
+ data
2745
+ );
2746
+ if (resolved != null && resolved !== '') {
2747
+ tooltipContent = `${
2748
+ iconData.tooltipPrefix || ''
2749
+ }${resolved}`;
2750
+ }
2751
+ }
2752
+
2416
2753
  addIcon(
2417
2754
  iconData.src ? iconData.src : null,
2418
2755
  iconData.icon ? iconData.icon : null,
2419
2756
  iconData.alt,
2420
- iconData.tooltipContent,
2757
+ tooltipContent,
2421
2758
  `coding-${config.id[0]}-${configKey}`
2422
2759
  );
2760
+ } else {
2761
+ // Empty placeholder slot so every config group occupies a
2762
+ // fixed position — icons align vertically across rows.
2763
+ icons.push(
2764
+ <div
2765
+ key={`coding-slot-${config.id[0]}-${configKey}`}
2766
+ style={{
2767
+ display: 'inline-flex',
2768
+ width: '28px',
2769
+ height: '28px',
2770
+ margin: '0 1px',
2771
+ }}
2772
+ />
2773
+ );
2423
2774
  }
2424
2775
  });
2425
2776
 
@@ -2545,13 +2896,7 @@ const DataGrid = forwardRef(
2545
2896
  // Check role-based permissions if roles are specified
2546
2897
  if (s.roles && Array.isArray(s.roles) && s.roles.length > 0) {
2547
2898
  // Check if user has any of the required roles
2548
- const hasRequiredRole = s.roles.some((requiredRole) =>
2549
- userProfile?.roles?.some(
2550
- (userRole) => userRole.name === requiredRole
2551
- )
2552
- );
2553
-
2554
- if (!hasRequiredRole) {
2899
+ if (!userHasAnyRole(s.roles)) {
2555
2900
  allow = false;
2556
2901
  }
2557
2902
  }
@@ -3022,6 +3367,8 @@ const DataGrid = forwardRef(
3022
3367
 
3023
3368
  // Settings that an 'action' column renders inline render there
3024
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.)
3025
3372
  const actionColumnSettingIds = new Set(
3026
3373
  (columns || [])
3027
3374
  .filter(
@@ -3030,6 +3377,7 @@ const DataGrid = forwardRef(
3030
3377
  Array.isArray(c.settings)
3031
3378
  )
3032
3379
  .flatMap((c) => c.settings)
3380
+ .filter((entry) => !isRowActionConfig(entry))
3033
3381
  );
3034
3382
 
3035
3383
  // Width to fit N action icons: tablet-mode icons have a larger
@@ -3193,15 +3541,26 @@ const DataGrid = forwardRef(
3193
3541
 
3194
3542
  switch (column.type) {
3195
3543
  case 'action': {
3196
- 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
+ )
3197
3552
  .map((id) =>
3198
3553
  memoizedSettings.find(
3199
3554
  (s) => s.id === id
3200
3555
  )
3201
3556
  )
3202
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
3203
3562
  const width = actionColumnWidthFor(
3204
- actionSettings.length
3563
+ actionSettings.length + rowActions.length
3205
3564
  );
3206
3565
  return {
3207
3566
  ...commonProps,
@@ -3219,6 +3578,9 @@ const DataGrid = forwardRef(
3219
3578
  {actionSettings.map((setting) =>
3220
3579
  renderSetting(setting, data)
3221
3580
  )}
3581
+ {rowActions.map((rowAction) =>
3582
+ renderRowAction(rowAction, data)
3583
+ )}
3222
3584
  </div>
3223
3585
  ),
3224
3586
  };
@@ -3596,8 +3958,188 @@ const DataGrid = forwardRef(
3596
3958
  };
3597
3959
  };
3598
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
+
3599
4132
  const newColumns = columns.map((column) =>
3600
- guardTreeParentRender(renderColumn(column), column)
4133
+ decorateColumnRowActions(
4134
+ decorateTreeParentActions(
4135
+ guardTreeParentRender(
4136
+ renderColumn(column),
4137
+ column
4138
+ ),
4139
+ column
4140
+ ),
4141
+ column
4142
+ )
3601
4143
  );
3602
4144
 
3603
4145
  // Count settings that could render in the trailing Action
@@ -4348,37 +4890,6 @@ const DataGrid = forwardRef(
4348
4890
  iconConfig,
4349
4891
  index
4350
4892
  ) => {
4351
- // Get the icon component based on the id
4352
- const getIconComponent =
4353
- (iconId) => {
4354
- switch (
4355
- iconId
4356
- ) {
4357
- case 'clock':
4358
- return Clock;
4359
- case 'alarm':
4360
- return AlarmClock;
4361
- case 'check':
4362
- return Check;
4363
- case 'edit':
4364
- return Edit;
4365
- case 'envelope':
4366
- return Mail;
4367
- case 'file':
4368
- return File;
4369
- case 'image':
4370
- return Image;
4371
- case 'copy':
4372
- return Copy;
4373
- case 'cloudUpload':
4374
- return Upload;
4375
- case 'cloudDownload':
4376
- return DownloadIcon;
4377
- default:
4378
- return Clock; // Default fallback
4379
- }
4380
- };
4381
-
4382
4893
  // Check if this icon should be shown based on show conditions
4383
4894
  if (
4384
4895
  !shouldShowGroupAction(
@@ -4390,7 +4901,7 @@ const DataGrid = forwardRef(
4390
4901
  }
4391
4902
 
4392
4903
  const IconComponent =
4393
- getIconComponent(
4904
+ getGroupIconComponent(
4394
4905
  iconConfig.id
4395
4906
  );
4396
4907