@visns-studio/visns-components 5.21.0 → 5.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -94,7 +94,7 @@
|
|
|
94
94
|
"react-dom": "^17.0.0 || ^18.0.0"
|
|
95
95
|
},
|
|
96
96
|
"name": "@visns-studio/visns-components",
|
|
97
|
-
"version": "5.
|
|
97
|
+
"version": "5.24.0",
|
|
98
98
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
99
99
|
"main": "src/index.js",
|
|
100
100
|
"files": [
|
|
@@ -95,6 +95,7 @@ import {
|
|
|
95
95
|
renderArrayCountColumn,
|
|
96
96
|
renderColourColumn,
|
|
97
97
|
renderDropdownColumn,
|
|
98
|
+
renderMultiDropdownColumn,
|
|
98
99
|
renderImageColumn,
|
|
99
100
|
renderFileColumn,
|
|
100
101
|
renderIconsColumn,
|
|
@@ -2762,7 +2763,7 @@ const DataGrid = forwardRef(
|
|
|
2762
2763
|
500
|
|
2763
2764
|
);
|
|
2764
2765
|
|
|
2765
|
-
const getDropdownData = async (ids, url, fields, where) => {
|
|
2766
|
+
const getDropdownData = async (ids, url, fields, where, orderBy) => {
|
|
2766
2767
|
// Concatenate id array into a single string if it's an array
|
|
2767
2768
|
const uniqueId = Array.isArray(ids) ? ids.join('') : ids;
|
|
2768
2769
|
|
|
@@ -2772,6 +2773,9 @@ const DataGrid = forwardRef(
|
|
|
2772
2773
|
const res = await CustomFetch(url, 'POST', {
|
|
2773
2774
|
fields,
|
|
2774
2775
|
where,
|
|
2776
|
+
// e.g. orderBy: 'sort_order' so option order follows
|
|
2777
|
+
// the managed sort instead of the endpoint default
|
|
2778
|
+
...(orderBy && { orderBy }),
|
|
2775
2779
|
});
|
|
2776
2780
|
resolve({ [uniqueId]: res.data.data }); // Correctly use uniqueId as the key
|
|
2777
2781
|
} catch (err) {
|
|
@@ -2783,13 +2787,16 @@ const DataGrid = forwardRef(
|
|
|
2783
2787
|
|
|
2784
2788
|
useEffect(() => {
|
|
2785
2789
|
const dropdownFetches = columns
|
|
2786
|
-
.filter((column) =>
|
|
2790
|
+
.filter((column) =>
|
|
2791
|
+
['dropdown', 'multiDropdown'].includes(column.type)
|
|
2792
|
+
)
|
|
2787
2793
|
.map((column) =>
|
|
2788
2794
|
getDropdownData(
|
|
2789
2795
|
column.id,
|
|
2790
2796
|
column.url,
|
|
2791
2797
|
column.fields,
|
|
2792
|
-
column.where
|
|
2798
|
+
column.where,
|
|
2799
|
+
column.orderBy
|
|
2793
2800
|
)
|
|
2794
2801
|
);
|
|
2795
2802
|
|
|
@@ -3079,6 +3086,15 @@ const DataGrid = forwardRef(
|
|
|
3079
3086
|
dropdownData,
|
|
3080
3087
|
handleDropdownUpdate,
|
|
3081
3088
|
});
|
|
3089
|
+
case 'multiDropdown':
|
|
3090
|
+
return renderMultiDropdownColumn({
|
|
3091
|
+
column,
|
|
3092
|
+
commonProps,
|
|
3093
|
+
filterEditor,
|
|
3094
|
+
filterEditorProps,
|
|
3095
|
+
dropdownData,
|
|
3096
|
+
handleDropdownUpdate,
|
|
3097
|
+
});
|
|
3082
3098
|
case 'file':
|
|
3083
3099
|
return renderFileColumn({
|
|
3084
3100
|
column,
|
package/src/components/Form.jsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import './styles/global.css';
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
|
3
|
+
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
|
4
4
|
import { useNavigate } from 'react-router-dom';
|
|
5
5
|
import { motion } from 'framer-motion';
|
|
6
6
|
import { toast } from 'react-toastify';
|
|
@@ -71,7 +71,7 @@ function Form({
|
|
|
71
71
|
closeModal,
|
|
72
72
|
columnId,
|
|
73
73
|
formType,
|
|
74
|
-
formSettings,
|
|
74
|
+
formSettings: formSettingsProp,
|
|
75
75
|
fetchTable,
|
|
76
76
|
mapbox,
|
|
77
77
|
modalOpen,
|
|
@@ -97,6 +97,35 @@ function Form({
|
|
|
97
97
|
const dataRef = useRef([]);
|
|
98
98
|
const groupClickTimeoutRef = useRef(null);
|
|
99
99
|
|
|
100
|
+
// Hide fields restricted to roles the user doesn't have (field.roles array)
|
|
101
|
+
const formSettings = useMemo(() => {
|
|
102
|
+
if (!formSettingsProp?.fields?.length) {
|
|
103
|
+
return formSettingsProp;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const fields = formSettingsProp.fields.filter((field) => {
|
|
107
|
+
if (
|
|
108
|
+
!field.roles ||
|
|
109
|
+
!Array.isArray(field.roles) ||
|
|
110
|
+
field.roles.length === 0
|
|
111
|
+
) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return field.roles.some((requiredRole) =>
|
|
116
|
+
userProfile?.roles?.some(
|
|
117
|
+
(userRole) => userRole.name === requiredRole
|
|
118
|
+
)
|
|
119
|
+
);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
if (fields.length === formSettingsProp.fields.length) {
|
|
123
|
+
return formSettingsProp;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { ...formSettingsProp, fields };
|
|
127
|
+
}, [formSettingsProp, userProfile]);
|
|
128
|
+
|
|
100
129
|
const childDropdownCallback = (data) => {
|
|
101
130
|
let _form = { ...formSettings };
|
|
102
131
|
|
|
@@ -1179,6 +1208,31 @@ function Form({
|
|
|
1179
1208
|
} else {
|
|
1180
1209
|
_inputClass[item.id] = ''; // Clear error class if not required
|
|
1181
1210
|
}
|
|
1211
|
+
|
|
1212
|
+
// Numeric bounds validation (min / greaterThan) for filled values
|
|
1213
|
+
const numericValue = formData[item.id];
|
|
1214
|
+
if (
|
|
1215
|
+
(item.min !== undefined ||
|
|
1216
|
+
item.greaterThan !== undefined) &&
|
|
1217
|
+
numericValue !== null &&
|
|
1218
|
+
numericValue !== undefined &&
|
|
1219
|
+
numericValue !== '' &&
|
|
1220
|
+
!isNaN(Number(numericValue))
|
|
1221
|
+
) {
|
|
1222
|
+
if (
|
|
1223
|
+
item.min !== undefined &&
|
|
1224
|
+
Number(numericValue) < item.min
|
|
1225
|
+
) {
|
|
1226
|
+
validation += `${item.label} must be at least ${item.min}.<br/>`;
|
|
1227
|
+
_inputClass[item.id] = styles.inputError;
|
|
1228
|
+
} else if (
|
|
1229
|
+
item.greaterThan !== undefined &&
|
|
1230
|
+
Number(numericValue) <= item.greaterThan
|
|
1231
|
+
) {
|
|
1232
|
+
validation += `${item.label} must be greater than ${item.greaterThan}.<br/>`;
|
|
1233
|
+
_inputClass[item.id] = styles.inputError;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1182
1236
|
});
|
|
1183
1237
|
|
|
1184
1238
|
setInputClass(_inputClass);
|
|
@@ -1448,6 +1502,8 @@ function Form({
|
|
|
1448
1502
|
let fileUpload = false;
|
|
1449
1503
|
let fileKey = '';
|
|
1450
1504
|
let fileFieldSetting = '';
|
|
1505
|
+
const fileKeys = [];
|
|
1506
|
+
const fileFieldSettings = {};
|
|
1451
1507
|
|
|
1452
1508
|
if (
|
|
1453
1509
|
formSettings.customUrl &&
|
|
@@ -1518,11 +1574,14 @@ function Form({
|
|
|
1518
1574
|
) {
|
|
1519
1575
|
fileUpload = true;
|
|
1520
1576
|
fileKey = item;
|
|
1577
|
+
fileKeys.push(item);
|
|
1521
1578
|
|
|
1522
1579
|
if (formSettings.fields.length > 0) {
|
|
1523
1580
|
formSettings.fields.forEach((field) => {
|
|
1524
1581
|
if (field.id === item) {
|
|
1525
1582
|
fileFieldSetting = field;
|
|
1583
|
+
fileFieldSettings[item] =
|
|
1584
|
+
field;
|
|
1526
1585
|
}
|
|
1527
1586
|
});
|
|
1528
1587
|
}
|
|
@@ -1608,60 +1667,134 @@ function Form({
|
|
|
1608
1667
|
updatedFormData['uploadedFiles'] =
|
|
1609
1668
|
await Promise.all(uploadPromises);
|
|
1610
1669
|
} else {
|
|
1611
|
-
//
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1670
|
+
// Single-File fields. JSON forms can carry
|
|
1671
|
+
// several in one save (e.g. a bio's photo +
|
|
1672
|
+
// document); upload each and attach a
|
|
1673
|
+
// field-scoped descriptor so the backend
|
|
1674
|
+
// stores the file against that exact field.
|
|
1675
|
+
// Non-JSON forms keep the original
|
|
1676
|
+
// one-file-per-save behaviour, driven by the
|
|
1677
|
+
// request-level metadata below.
|
|
1678
|
+
const singleFileKeys = formSettings.json
|
|
1679
|
+
? fileKeys.filter(
|
|
1680
|
+
(k) =>
|
|
1681
|
+
updatedFormData[k] instanceof
|
|
1682
|
+
File
|
|
1683
|
+
)
|
|
1684
|
+
: [fileKey];
|
|
1685
|
+
|
|
1686
|
+
for (const fieldId of singleFileKeys) {
|
|
1687
|
+
const originalName =
|
|
1688
|
+
updatedFormData[fieldId].name;
|
|
1689
|
+
|
|
1690
|
+
// Optimize image if it's an image file
|
|
1691
|
+
const optimizedFile =
|
|
1692
|
+
await optimizeImageIfNeeded(
|
|
1693
|
+
updatedFormData[fieldId]
|
|
1694
|
+
);
|
|
1695
|
+
|
|
1696
|
+
const toastId = toast.info(
|
|
1697
|
+
`Uploading ${originalName}`,
|
|
1698
|
+
{
|
|
1699
|
+
progress: 0,
|
|
1700
|
+
autoClose: false,
|
|
1701
|
+
}
|
|
1615
1702
|
);
|
|
1616
1703
|
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1704
|
+
const fileRes = await Vapor.store(
|
|
1705
|
+
optimizedFile,
|
|
1706
|
+
{
|
|
1707
|
+
progress: (progress) => {
|
|
1708
|
+
setUploadProgress(
|
|
1709
|
+
progress * 100
|
|
1710
|
+
);
|
|
1711
|
+
toast.update(toastId, {
|
|
1712
|
+
progress,
|
|
1713
|
+
});
|
|
1714
|
+
},
|
|
1715
|
+
}
|
|
1716
|
+
);
|
|
1624
1717
|
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1718
|
+
toast.update(toastId, {
|
|
1719
|
+
render: `${originalName} uploaded successfully!`,
|
|
1720
|
+
type: 'success',
|
|
1721
|
+
autoClose: 3000,
|
|
1722
|
+
});
|
|
1723
|
+
|
|
1724
|
+
updatedFormData['uuid'] = fileRes.uuid;
|
|
1725
|
+
updatedFormData['key'] = fileRes.key;
|
|
1726
|
+
updatedFormData['bucket'] = fileRes.bucket;
|
|
1727
|
+
updatedFormData['filename'] = originalName;
|
|
1728
|
+
updatedFormData['filesize'] =
|
|
1729
|
+
optimizedFile.size;
|
|
1730
|
+
updatedFormData['extension'] =
|
|
1731
|
+
fileRes.extension;
|
|
1732
|
+
updatedFormData['file_relationship'] =
|
|
1733
|
+
fieldId;
|
|
1734
|
+
updatedFormData['fileable_field'] = (
|
|
1735
|
+
fileFieldSettings[fieldId] ||
|
|
1736
|
+
fileFieldSetting
|
|
1737
|
+
).fileable_field;
|
|
1738
|
+
|
|
1739
|
+
if (formSettings.json) {
|
|
1740
|
+
// Replace the raw File (which would
|
|
1741
|
+
// serialise to junk in the JSON
|
|
1742
|
+
// payload) with an upload descriptor
|
|
1743
|
+
// the backend materialises into a
|
|
1744
|
+
// {file_path, file_name, ...} object.
|
|
1745
|
+
updatedFormData[fieldId] = {
|
|
1746
|
+
_vaporUpload: true,
|
|
1747
|
+
uuid: fileRes.uuid,
|
|
1748
|
+
key: fileRes.key,
|
|
1749
|
+
bucket: fileRes.bucket,
|
|
1750
|
+
filename: originalName,
|
|
1751
|
+
filesize: optimizedFile.size,
|
|
1752
|
+
extension: fileRes.extension,
|
|
1753
|
+
};
|
|
1634
1754
|
}
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
toast.update(toastId, {
|
|
1638
|
-
render: `${updatedFormData[fileKey].name} uploaded successfully!`,
|
|
1639
|
-
type: 'success',
|
|
1640
|
-
autoClose: 3000,
|
|
1641
|
-
});
|
|
1642
|
-
|
|
1643
|
-
updatedFormData['uuid'] = fileRes.uuid;
|
|
1644
|
-
updatedFormData['key'] = fileRes.key;
|
|
1645
|
-
updatedFormData['bucket'] = fileRes.bucket;
|
|
1646
|
-
updatedFormData['filename'] =
|
|
1647
|
-
updatedFormData[fileKey].name;
|
|
1648
|
-
updatedFormData['filesize'] =
|
|
1649
|
-
optimizedFile.size;
|
|
1650
|
-
updatedFormData['extension'] =
|
|
1651
|
-
fileRes.extension;
|
|
1652
|
-
updatedFormData['file_relationship'] = fileKey;
|
|
1653
|
-
updatedFormData['fileable_field'] =
|
|
1654
|
-
fileFieldSetting.fileable_field;
|
|
1755
|
+
}
|
|
1655
1756
|
}
|
|
1656
1757
|
}
|
|
1657
1758
|
|
|
1658
|
-
// Transform keys with dots into nested objects
|
|
1759
|
+
// Transform keys with dots into nested objects.
|
|
1760
|
+
// Walks every dot segment (not just two) so ids like
|
|
1761
|
+
// "transport.visit_before_after.yes_before_other" nest
|
|
1762
|
+
// correctly, and merges plain objects instead of
|
|
1763
|
+
// replacing them so sibling keys written by other
|
|
1764
|
+
// fields on the same path survive regardless of key
|
|
1765
|
+
// order.
|
|
1766
|
+
const isPlainObject = (value) =>
|
|
1767
|
+
value !== null &&
|
|
1768
|
+
typeof value === 'object' &&
|
|
1769
|
+
(value.constructor === Object ||
|
|
1770
|
+
value.constructor === undefined);
|
|
1771
|
+
|
|
1659
1772
|
let payload = Object.keys(updatedFormData).reduce(
|
|
1660
1773
|
(acc, key) => {
|
|
1661
1774
|
if (key.includes('.')) {
|
|
1662
|
-
const
|
|
1663
|
-
|
|
1664
|
-
|
|
1775
|
+
const segments = key.split('.');
|
|
1776
|
+
let node = acc;
|
|
1777
|
+
|
|
1778
|
+
for (
|
|
1779
|
+
let i = 0;
|
|
1780
|
+
i < segments.length - 1;
|
|
1781
|
+
i++
|
|
1782
|
+
) {
|
|
1783
|
+
if (!isPlainObject(node[segments[i]])) {
|
|
1784
|
+
node[segments[i]] = {};
|
|
1785
|
+
}
|
|
1786
|
+
node = node[segments[i]];
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const last =
|
|
1790
|
+
segments[segments.length - 1];
|
|
1791
|
+
const value = updatedFormData[key];
|
|
1792
|
+
|
|
1793
|
+
node[last] =
|
|
1794
|
+
isPlainObject(value) &&
|
|
1795
|
+
isPlainObject(node[last])
|
|
1796
|
+
? { ...node[last], ...value }
|
|
1797
|
+
: value;
|
|
1665
1798
|
} else {
|
|
1666
1799
|
acc[key] = updatedFormData[key];
|
|
1667
1800
|
}
|
|
@@ -1737,6 +1870,7 @@ function Form({
|
|
|
1737
1870
|
// In bulk edit mode, call saveAnother for each row in the group
|
|
1738
1871
|
if (bulkEditMode && bulkEditGroupData?.groupRowIds?.length) {
|
|
1739
1872
|
let hasError = false;
|
|
1873
|
+
const saveAnotherErrors = [];
|
|
1740
1874
|
for (const rowId of bulkEditGroupData.groupRowIds) {
|
|
1741
1875
|
const saveAnotherUrl = `${saveAnother.url}/${rowId}`;
|
|
1742
1876
|
const resSaveAnother =
|
|
@@ -1747,12 +1881,27 @@ function Form({
|
|
|
1747
1881
|
);
|
|
1748
1882
|
if (resSaveAnother.data.error !== '') {
|
|
1749
1883
|
hasError = true;
|
|
1884
|
+
if (
|
|
1885
|
+
!saveAnotherErrors.includes(
|
|
1886
|
+
resSaveAnother.data
|
|
1887
|
+
.error
|
|
1888
|
+
)
|
|
1889
|
+
) {
|
|
1890
|
+
saveAnotherErrors.push(
|
|
1891
|
+
resSaveAnother.data
|
|
1892
|
+
.error
|
|
1893
|
+
);
|
|
1894
|
+
}
|
|
1750
1895
|
}
|
|
1751
1896
|
}
|
|
1752
1897
|
if (!hasError) {
|
|
1753
1898
|
toast.success(saveAnother.message);
|
|
1754
1899
|
} else {
|
|
1755
1900
|
saveAnotherFailed = true;
|
|
1901
|
+
saveAnotherErrors.forEach(
|
|
1902
|
+
(error) =>
|
|
1903
|
+
toast.error(error)
|
|
1904
|
+
);
|
|
1756
1905
|
}
|
|
1757
1906
|
if (fetchTable) {
|
|
1758
1907
|
const createdItem =
|
|
@@ -1779,6 +1928,9 @@ function Form({
|
|
|
1779
1928
|
}
|
|
1780
1929
|
} else {
|
|
1781
1930
|
saveAnotherFailed = true;
|
|
1931
|
+
toast.error(
|
|
1932
|
+
resSaveAnother.data.error
|
|
1933
|
+
);
|
|
1782
1934
|
if (fetchTable) {
|
|
1783
1935
|
const createdItem =
|
|
1784
1936
|
res.data.data || res.data;
|
|
@@ -2115,10 +2267,12 @@ function Form({
|
|
|
2115
2267
|
let value = data;
|
|
2116
2268
|
|
|
2117
2269
|
for (let key of keys) {
|
|
2118
|
-
|
|
2270
|
+
// ?? (not ||) so falsy-but-real values like
|
|
2271
|
+
// false, 0 and '' survive the walk
|
|
2272
|
+
value = value?.[key] ?? null;
|
|
2119
2273
|
|
|
2120
2274
|
// If value becomes null, break out of the loop
|
|
2121
|
-
if (
|
|
2275
|
+
if (value === null) break;
|
|
2122
2276
|
}
|
|
2123
2277
|
|
|
2124
2278
|
updatedFormData[item] = value;
|
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react';
|
|
1
2
|
import numeral from 'numeral';
|
|
2
3
|
import moment from 'moment';
|
|
3
4
|
import parse from 'html-react-parser';
|
|
5
|
+
import Select from 'react-select';
|
|
6
|
+
import CreatableSelect from 'react-select/creatable';
|
|
7
|
+
import CustomFetch from '../Fetch';
|
|
4
8
|
import { Download, AlarmClock } from 'lucide-react';
|
|
5
9
|
import { toast } from 'react-toastify';
|
|
6
10
|
import CellWithTooltip from '../cells/CellWithTooltip';
|
|
@@ -345,6 +349,168 @@ export const renderColourColumn = ({ column, commonProps }) => {
|
|
|
345
349
|
};
|
|
346
350
|
|
|
347
351
|
// Dropdown Column Renderer
|
|
352
|
+
// Compact react-select styling shared by the grid cell editors
|
|
353
|
+
const compactSelectStyles = {
|
|
354
|
+
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
355
|
+
control: (base) => ({
|
|
356
|
+
...base,
|
|
357
|
+
border: 'none',
|
|
358
|
+
boxShadow: 'none',
|
|
359
|
+
backgroundColor: 'transparent',
|
|
360
|
+
minHeight: 'unset',
|
|
361
|
+
}),
|
|
362
|
+
valueContainer: (base) => ({ ...base, padding: '0 4px' }),
|
|
363
|
+
multiValue: (base) => ({ ...base, margin: '1px 2px' }),
|
|
364
|
+
multiValueLabel: (base) => ({
|
|
365
|
+
...base,
|
|
366
|
+
fontSize: '12px',
|
|
367
|
+
padding: '1px 4px',
|
|
368
|
+
}),
|
|
369
|
+
placeholder: (base) => ({ ...base, color: '#777' }),
|
|
370
|
+
indicatorSeparator: () => ({ display: 'none' }),
|
|
371
|
+
dropdownIndicator: (base) => ({ ...base, padding: '0 4px' }),
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
// Inline editor for the free-text companion of a multiDropdown's "Other"
|
|
375
|
+
// option (column.other = {matchLabel, key, placeholder}). Commits on
|
|
376
|
+
// blur/Enter via the column's update endpoint.
|
|
377
|
+
const OtherValueInput = ({ column, data, handleDropdownUpdate }) => {
|
|
378
|
+
const current = data[column.other.key] ?? '';
|
|
379
|
+
const [value, setValue] = useState(current);
|
|
380
|
+
|
|
381
|
+
useEffect(() => {
|
|
382
|
+
setValue(current);
|
|
383
|
+
}, [current]);
|
|
384
|
+
|
|
385
|
+
const commit = () => {
|
|
386
|
+
if (
|
|
387
|
+
value === current ||
|
|
388
|
+
!column.update?.key ||
|
|
389
|
+
!column.update?.url ||
|
|
390
|
+
!column.update?.method
|
|
391
|
+
) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
handleDropdownUpdate(
|
|
396
|
+
column.other.key,
|
|
397
|
+
`${column.update.url}/${data.id}`,
|
|
398
|
+
column.update.method,
|
|
399
|
+
value
|
|
400
|
+
);
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
return (
|
|
404
|
+
<input
|
|
405
|
+
type="text"
|
|
406
|
+
value={value}
|
|
407
|
+
placeholder={column.other.placeholder || 'Other…'}
|
|
408
|
+
onChange={(e) => setValue(e.target.value)}
|
|
409
|
+
onBlur={commit}
|
|
410
|
+
onKeyDown={(e) => {
|
|
411
|
+
if (e.key === 'Enter') e.target.blur();
|
|
412
|
+
}}
|
|
413
|
+
onClick={(e) => e.stopPropagation()}
|
|
414
|
+
style={{
|
|
415
|
+
width: '100%',
|
|
416
|
+
marginTop: '2px',
|
|
417
|
+
padding: '3px 6px',
|
|
418
|
+
fontSize: '12px',
|
|
419
|
+
border: '1px dashed #b9b9b9',
|
|
420
|
+
borderRadius: '4px',
|
|
421
|
+
outline: 'none',
|
|
422
|
+
backgroundColor: '#fffdf2',
|
|
423
|
+
}}
|
|
424
|
+
/>
|
|
425
|
+
);
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// Single-select cell that can also create new entries on the fly
|
|
429
|
+
// (column.creatable = {url, field, payload?}): typing a value not in
|
|
430
|
+
// the options offers an "Add …" action that POSTs to the entity
|
|
431
|
+
// endpoint and assigns the created id to the row.
|
|
432
|
+
const CreatableDropdownCell = ({
|
|
433
|
+
column,
|
|
434
|
+
data,
|
|
435
|
+
optionsList,
|
|
436
|
+
handleDropdownUpdate,
|
|
437
|
+
}) => {
|
|
438
|
+
const [creating, setCreating] = useState(false);
|
|
439
|
+
|
|
440
|
+
const options = optionsList.map((o) => ({ value: o.id, label: o.label }));
|
|
441
|
+
const currentId = data[column.id]?.id || data[column.id] || '';
|
|
442
|
+
const currentOption =
|
|
443
|
+
options.find((o) => o.value === currentId) || null;
|
|
444
|
+
|
|
445
|
+
const fallbackText =
|
|
446
|
+
!currentId && column.fallbackKey && data[column.fallbackKey]
|
|
447
|
+
? String(data[column.fallbackKey])
|
|
448
|
+
: null;
|
|
449
|
+
|
|
450
|
+
const assign = (value) => {
|
|
451
|
+
if (column.update?.key && column.update?.url && column.update?.method) {
|
|
452
|
+
handleDropdownUpdate(
|
|
453
|
+
column.update.key,
|
|
454
|
+
`${column.update.url}/${data.id}`,
|
|
455
|
+
column.update.method,
|
|
456
|
+
value
|
|
457
|
+
);
|
|
458
|
+
}
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const handleCreate = async (inputValue) => {
|
|
462
|
+
const label = String(inputValue ?? '').trim();
|
|
463
|
+
if (!label || !column.creatable?.url || !column.creatable?.field) {
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
setCreating(true);
|
|
468
|
+
try {
|
|
469
|
+
const res = await CustomFetch(column.creatable.url, 'POST', {
|
|
470
|
+
[column.creatable.field]: label,
|
|
471
|
+
...(column.creatable.payload || {}),
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
const created = res?.data?.data;
|
|
475
|
+
if (res?.data?.error === '' && created?.id) {
|
|
476
|
+
assign(created.id);
|
|
477
|
+
} else {
|
|
478
|
+
toast.error(res?.data?.error || 'Could not create entry');
|
|
479
|
+
}
|
|
480
|
+
} catch (err) {
|
|
481
|
+
console.error(err);
|
|
482
|
+
toast.error('Could not create entry');
|
|
483
|
+
} finally {
|
|
484
|
+
setCreating(false);
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
return (
|
|
489
|
+
<CreatableSelect
|
|
490
|
+
options={options}
|
|
491
|
+
value={currentOption}
|
|
492
|
+
placeholder={
|
|
493
|
+
fallbackText ||
|
|
494
|
+
`Select ${/^[aeioAEIO]/.test(column.label) ? 'an' : 'a'} ${
|
|
495
|
+
column.label
|
|
496
|
+
}`
|
|
497
|
+
}
|
|
498
|
+
isDisabled={creating}
|
|
499
|
+
isLoading={creating}
|
|
500
|
+
menuPortalTarget={
|
|
501
|
+
typeof document !== 'undefined' ? document.body : undefined
|
|
502
|
+
}
|
|
503
|
+
menuPlacement="auto"
|
|
504
|
+
formatCreateLabel={(v) => `Add "${v}"`}
|
|
505
|
+
styles={compactSelectStyles}
|
|
506
|
+
onChange={(selected) => {
|
|
507
|
+
if (selected) assign(selected.value);
|
|
508
|
+
}}
|
|
509
|
+
onCreateOption={handleCreate}
|
|
510
|
+
/>
|
|
511
|
+
);
|
|
512
|
+
};
|
|
513
|
+
|
|
348
514
|
export const renderDropdownColumn = ({
|
|
349
515
|
column,
|
|
350
516
|
commonProps,
|
|
@@ -361,16 +527,67 @@ export const renderDropdownColumn = ({
|
|
|
361
527
|
filterEditor: filterEditor,
|
|
362
528
|
filterEditorProps: filterEditorProps,
|
|
363
529
|
render: ({ data }) => {
|
|
530
|
+
let optionsList =
|
|
531
|
+
dropdownData[column.id] && dropdownData[column.id].length > 0
|
|
532
|
+
? dropdownData[column.id]
|
|
533
|
+
: column.options || [];
|
|
534
|
+
|
|
535
|
+
// Keep historical selections visible/selectable: if the row's
|
|
536
|
+
// current related entry isn't in the options (e.g. it has been
|
|
537
|
+
// deactivated), prepend it so the select can still display it.
|
|
538
|
+
const currentRelated = data[column.id];
|
|
539
|
+
if (
|
|
540
|
+
currentRelated &&
|
|
541
|
+
typeof currentRelated === 'object' &&
|
|
542
|
+
currentRelated.id &&
|
|
543
|
+
!optionsList.some((o) => o.id === currentRelated.id)
|
|
544
|
+
) {
|
|
545
|
+
const currentLabel =
|
|
546
|
+
currentRelated.label ??
|
|
547
|
+
currentRelated.name ??
|
|
548
|
+
currentRelated.title;
|
|
549
|
+
if (currentLabel) {
|
|
550
|
+
optionsList = [
|
|
551
|
+
{ id: currentRelated.id, label: currentLabel },
|
|
552
|
+
...optionsList,
|
|
553
|
+
];
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// creatable: swap the native select for a react-select that
|
|
558
|
+
// can add new entries to the source entity on the fly
|
|
559
|
+
if (column.creatable) {
|
|
560
|
+
return (
|
|
561
|
+
<CreatableDropdownCell
|
|
562
|
+
column={column}
|
|
563
|
+
data={data}
|
|
564
|
+
optionsList={optionsList}
|
|
565
|
+
handleDropdownUpdate={handleDropdownUpdate}
|
|
566
|
+
/>
|
|
567
|
+
);
|
|
568
|
+
}
|
|
569
|
+
|
|
364
570
|
return (
|
|
365
571
|
<select
|
|
366
572
|
name={column.id}
|
|
367
573
|
value={data[column.id]?.id || data[column.id] || ''}
|
|
368
|
-
style={{
|
|
369
|
-
padding: '7px',
|
|
574
|
+
style={{
|
|
575
|
+
padding: '7px 22px 7px 7px',
|
|
370
576
|
width: '100%',
|
|
371
577
|
border: 'none',
|
|
372
578
|
backgroundColor: 'transparent',
|
|
373
579
|
outline: 'none',
|
|
580
|
+
cursor: 'pointer',
|
|
581
|
+
// Consistent affordance across browsers: strip the
|
|
582
|
+
// native arrow and draw a subtle chevron so the
|
|
583
|
+
// cell visibly reads as a dropdown
|
|
584
|
+
appearance: 'none',
|
|
585
|
+
WebkitAppearance: 'none',
|
|
586
|
+
MozAppearance: 'none',
|
|
587
|
+
backgroundImage:
|
|
588
|
+
"url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>\")",
|
|
589
|
+
backgroundRepeat: 'no-repeat',
|
|
590
|
+
backgroundPosition: 'right 6px center',
|
|
374
591
|
}}
|
|
375
592
|
onChange={(e) => {
|
|
376
593
|
e.preventDefault();
|
|
@@ -390,23 +607,23 @@ export const renderDropdownColumn = ({
|
|
|
390
607
|
}}
|
|
391
608
|
>
|
|
392
609
|
<option value="">
|
|
393
|
-
|
|
394
|
-
|
|
610
|
+
{/* fallbackKey: surface a legacy free-text value
|
|
611
|
+
(recorded before the relation existed) as the
|
|
612
|
+
placeholder so it stays readable until a managed
|
|
613
|
+
option is chosen */}
|
|
614
|
+
{!(data[column.id]?.id || data[column.id]) &&
|
|
615
|
+
column.fallbackKey &&
|
|
616
|
+
data[column.fallbackKey]
|
|
617
|
+
? String(data[column.fallbackKey])
|
|
618
|
+
: `Select ${
|
|
619
|
+
/^[aeioAEIO]/.test(column.label) ? 'an' : 'a'
|
|
620
|
+
} ${column.label}`}
|
|
395
621
|
</option>
|
|
396
|
-
{
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
</option>
|
|
402
|
-
))
|
|
403
|
-
: column.options && column.options.length > 0
|
|
404
|
-
? column.options.map((option, index) => (
|
|
405
|
-
<option value={option.id} key={index}>
|
|
406
|
-
{option.label}
|
|
407
|
-
</option>
|
|
408
|
-
))
|
|
409
|
-
: null}
|
|
622
|
+
{optionsList.map((option, index) => (
|
|
623
|
+
<option value={option.id} key={index}>
|
|
624
|
+
{option.label}
|
|
625
|
+
</option>
|
|
626
|
+
))}
|
|
410
627
|
</select>
|
|
411
628
|
);
|
|
412
629
|
},
|
|
@@ -452,6 +669,111 @@ export const renderDropdownColumn = ({
|
|
|
452
669
|
};
|
|
453
670
|
};
|
|
454
671
|
|
|
672
|
+
// MultiDropdown Column Renderer — inline editor for belongsToMany
|
|
673
|
+
// relations. Chips come from the row's loaded relation (so historical
|
|
674
|
+
// selections of deactivated entries keep displaying); options come from
|
|
675
|
+
// the column's dropdown url; saving PUTs the selected id array to
|
|
676
|
+
// column.update.key, which DynamicController syncs on the pivot.
|
|
677
|
+
export const renderMultiDropdownColumn = ({
|
|
678
|
+
column,
|
|
679
|
+
commonProps,
|
|
680
|
+
filterEditor,
|
|
681
|
+
filterEditorProps,
|
|
682
|
+
dropdownData,
|
|
683
|
+
handleDropdownUpdate,
|
|
684
|
+
}) => {
|
|
685
|
+
const columnId = Array.isArray(column.id) ? column.id.join('-') : column.id;
|
|
686
|
+
const nameFrom = column.nameFrom || 'label';
|
|
687
|
+
|
|
688
|
+
return {
|
|
689
|
+
...commonProps,
|
|
690
|
+
name: columnId,
|
|
691
|
+
filterEditor: filterEditor,
|
|
692
|
+
filterEditorProps: filterEditorProps,
|
|
693
|
+
render: ({ data }) => {
|
|
694
|
+
const relationKey = Array.isArray(column.id)
|
|
695
|
+
? column.id[0]
|
|
696
|
+
: column.id;
|
|
697
|
+
const current = Array.isArray(data[relationKey])
|
|
698
|
+
? data[relationKey]
|
|
699
|
+
: [];
|
|
700
|
+
|
|
701
|
+
const value = current.map((entry) => ({
|
|
702
|
+
value: entry.id,
|
|
703
|
+
label: entry[nameFrom] ?? entry.label ?? entry.name ?? '',
|
|
704
|
+
}));
|
|
705
|
+
|
|
706
|
+
const options = (
|
|
707
|
+
dropdownData[column.id]?.length
|
|
708
|
+
? dropdownData[column.id]
|
|
709
|
+
: column.options || []
|
|
710
|
+
).map((o) => ({ value: o.id, label: o.label }));
|
|
711
|
+
|
|
712
|
+
// fallbackKey: a legacy free-text value shows as the
|
|
713
|
+
// placeholder until managed entries are selected
|
|
714
|
+
const placeholder =
|
|
715
|
+
value.length === 0 &&
|
|
716
|
+
column.fallbackKey &&
|
|
717
|
+
data[column.fallbackKey]
|
|
718
|
+
? String(data[column.fallbackKey])
|
|
719
|
+
: 'Select...';
|
|
720
|
+
|
|
721
|
+
// other: when the option matching other.matchLabel is
|
|
722
|
+
// selected, show a free-text input for its companion value
|
|
723
|
+
// (e.g. the "Other" tour format text)
|
|
724
|
+
const otherSelected =
|
|
725
|
+
column.other &&
|
|
726
|
+
value.some(
|
|
727
|
+
(v) =>
|
|
728
|
+
String(v.label).toLowerCase() ===
|
|
729
|
+
String(
|
|
730
|
+
column.other.matchLabel ?? 'Other'
|
|
731
|
+
).toLowerCase()
|
|
732
|
+
);
|
|
733
|
+
|
|
734
|
+
return (
|
|
735
|
+
<div>
|
|
736
|
+
<Select
|
|
737
|
+
isMulti
|
|
738
|
+
options={options}
|
|
739
|
+
value={value}
|
|
740
|
+
placeholder={placeholder}
|
|
741
|
+
isClearable={false}
|
|
742
|
+
menuPortalTarget={
|
|
743
|
+
typeof document !== 'undefined'
|
|
744
|
+
? document.body
|
|
745
|
+
: undefined
|
|
746
|
+
}
|
|
747
|
+
menuPlacement="auto"
|
|
748
|
+
styles={compactSelectStyles}
|
|
749
|
+
onChange={(selected) => {
|
|
750
|
+
if (
|
|
751
|
+
column.update?.key &&
|
|
752
|
+
column.update?.url &&
|
|
753
|
+
column.update?.method
|
|
754
|
+
) {
|
|
755
|
+
handleDropdownUpdate(
|
|
756
|
+
column.update.key,
|
|
757
|
+
`${column.update.url}/${data.id}`,
|
|
758
|
+
column.update.method,
|
|
759
|
+
(selected || []).map((s) => s.value)
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
}}
|
|
763
|
+
/>
|
|
764
|
+
{otherSelected && (
|
|
765
|
+
<OtherValueInput
|
|
766
|
+
column={column}
|
|
767
|
+
data={data}
|
|
768
|
+
handleDropdownUpdate={handleDropdownUpdate}
|
|
769
|
+
/>
|
|
770
|
+
)}
|
|
771
|
+
</div>
|
|
772
|
+
);
|
|
773
|
+
},
|
|
774
|
+
};
|
|
775
|
+
};
|
|
776
|
+
|
|
455
777
|
// Image Column Renderer
|
|
456
778
|
export const renderImageColumn = ({ column, commonProps }) => {
|
|
457
779
|
return {
|
|
@@ -1524,6 +1846,17 @@ export const renderRelationArrayColumn = ({
|
|
|
1524
1846
|
: '';
|
|
1525
1847
|
}
|
|
1526
1848
|
|
|
1849
|
+
// fallbackKey: when the relation is empty, display a flat
|
|
1850
|
+
// field from the row instead (e.g. legacy free-text values
|
|
1851
|
+
// recorded before the relation existed)
|
|
1852
|
+
if (
|
|
1853
|
+
result === '' &&
|
|
1854
|
+
column.fallbackKey &&
|
|
1855
|
+
data[column.fallbackKey]
|
|
1856
|
+
) {
|
|
1857
|
+
result = String(data[column.fallbackKey]);
|
|
1858
|
+
}
|
|
1859
|
+
|
|
1527
1860
|
return (
|
|
1528
1861
|
<CellWithTooltip
|
|
1529
1862
|
value={result}
|
|
@@ -885,11 +885,35 @@ function GenericDetail({
|
|
|
885
885
|
size,
|
|
886
886
|
truncateLength,
|
|
887
887
|
} = item;
|
|
888
|
-
|
|
888
|
+
let value =
|
|
889
889
|
relation && relation.length > 0 && type !== 'total'
|
|
890
890
|
? getValueFromData(data, [...relation], id, type)
|
|
891
891
|
: getValueFromData(data, [], id, type);
|
|
892
892
|
|
|
893
|
+
// fallbackKey: when the resolved (relation) value is empty, fall
|
|
894
|
+
// back to a flat field on the record — keeps legacy free-text
|
|
895
|
+
// values visible after a field moves to a managed relation
|
|
896
|
+
if (
|
|
897
|
+
(value === '' || value === null || value === undefined) &&
|
|
898
|
+
item.fallbackKey &&
|
|
899
|
+
data[item.fallbackKey]
|
|
900
|
+
) {
|
|
901
|
+
value = data[item.fallbackKey];
|
|
902
|
+
}
|
|
903
|
+
// suffixKey: append a companion flat field in parentheses (e.g.
|
|
904
|
+
// the "Other" free text alongside managed selections). Skipped
|
|
905
|
+
// when the value IS that field already (fallback case above).
|
|
906
|
+
else if (
|
|
907
|
+
value !== '' &&
|
|
908
|
+
value !== null &&
|
|
909
|
+
value !== undefined &&
|
|
910
|
+
item.suffixKey &&
|
|
911
|
+
data[item.suffixKey] &&
|
|
912
|
+
data[item.suffixKey] !== value
|
|
913
|
+
) {
|
|
914
|
+
value = `${value} (${data[item.suffixKey]})`;
|
|
915
|
+
}
|
|
916
|
+
|
|
893
917
|
const stringValue = String(value ?? ''); // Convert value to a string
|
|
894
918
|
|
|
895
919
|
// Conditionally truncate the value if the 'truncate' key is present
|