@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 +1 -1
- package/src/components/DataGrid.jsx +577 -111
- 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
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.
|
|
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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
4904
|
+
getGroupIconComponent(
|
|
4439
4905
|
iconConfig.id
|
|
4440
4906
|
);
|
|
4441
4907
|
|
package/src/components/Form.jsx
CHANGED
|
@@ -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
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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 {
|