@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.
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +624 -113
- package/src/components/Form.jsx +150 -15
- package/src/components/MultiSelect.jsx +30 -2
- package/src/components/styles/DataGrid.module.scss +22 -4
- package/src/components/styles/MultiSelect.module.scss +10 -10
- package/src/components/styles/global.css +14 -10
|
@@ -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
|
|
608
|
-
//
|
|
609
|
-
//
|
|
610
|
-
//
|
|
611
|
-
// first row onto the parent and blanked on the
|
|
612
|
-
// so shared details display once; children get
|
|
613
|
-
// prefix in the display column. Single rows stay
|
|
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
|
-
|
|
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
|
|
1620
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
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
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
-
|
|
1696
|
-
|
|
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
|
-
|
|
1717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4904
|
+
getGroupIconComponent(
|
|
4394
4905
|
iconConfig.id
|
|
4395
4906
|
);
|
|
4396
4907
|
|