@visns-studio/visns-components 5.22.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": [
|
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);
|
|
@@ -1816,6 +1870,7 @@ function Form({
|
|
|
1816
1870
|
// In bulk edit mode, call saveAnother for each row in the group
|
|
1817
1871
|
if (bulkEditMode && bulkEditGroupData?.groupRowIds?.length) {
|
|
1818
1872
|
let hasError = false;
|
|
1873
|
+
const saveAnotherErrors = [];
|
|
1819
1874
|
for (const rowId of bulkEditGroupData.groupRowIds) {
|
|
1820
1875
|
const saveAnotherUrl = `${saveAnother.url}/${rowId}`;
|
|
1821
1876
|
const resSaveAnother =
|
|
@@ -1826,12 +1881,27 @@ function Form({
|
|
|
1826
1881
|
);
|
|
1827
1882
|
if (resSaveAnother.data.error !== '') {
|
|
1828
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
|
+
}
|
|
1829
1895
|
}
|
|
1830
1896
|
}
|
|
1831
1897
|
if (!hasError) {
|
|
1832
1898
|
toast.success(saveAnother.message);
|
|
1833
1899
|
} else {
|
|
1834
1900
|
saveAnotherFailed = true;
|
|
1901
|
+
saveAnotherErrors.forEach(
|
|
1902
|
+
(error) =>
|
|
1903
|
+
toast.error(error)
|
|
1904
|
+
);
|
|
1835
1905
|
}
|
|
1836
1906
|
if (fetchTable) {
|
|
1837
1907
|
const createdItem =
|
|
@@ -1858,6 +1928,9 @@ function Form({
|
|
|
1858
1928
|
}
|
|
1859
1929
|
} else {
|
|
1860
1930
|
saveAnotherFailed = true;
|
|
1931
|
+
toast.error(
|
|
1932
|
+
resSaveAnother.data.error
|
|
1933
|
+
);
|
|
1861
1934
|
if (fetchTable) {
|
|
1862
1935
|
const createdItem =
|
|
1863
1936
|
res.data.data || res.data;
|
|
@@ -1,7 +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';
|
|
4
5
|
import Select from 'react-select';
|
|
6
|
+
import CreatableSelect from 'react-select/creatable';
|
|
7
|
+
import CustomFetch from '../Fetch';
|
|
5
8
|
import { Download, AlarmClock } from 'lucide-react';
|
|
6
9
|
import { toast } from 'react-toastify';
|
|
7
10
|
import CellWithTooltip from '../cells/CellWithTooltip';
|
|
@@ -346,6 +349,168 @@ export const renderColourColumn = ({ column, commonProps }) => {
|
|
|
346
349
|
};
|
|
347
350
|
|
|
348
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
|
+
|
|
349
514
|
export const renderDropdownColumn = ({
|
|
350
515
|
column,
|
|
351
516
|
commonProps,
|
|
@@ -389,6 +554,19 @@ export const renderDropdownColumn = ({
|
|
|
389
554
|
}
|
|
390
555
|
}
|
|
391
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
|
+
|
|
392
570
|
return (
|
|
393
571
|
<select
|
|
394
572
|
name={column.id}
|
|
@@ -540,66 +718,57 @@ export const renderMultiDropdownColumn = ({
|
|
|
540
718
|
? String(data[column.fallbackKey])
|
|
541
719
|
: 'Select...';
|
|
542
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
|
+
|
|
543
734
|
return (
|
|
544
|
-
<
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
menuPlacement="auto"
|
|
556
|
-
styles={{
|
|
557
|
-
menuPortal: (base) => ({ ...base, zIndex: 9999 }),
|
|
558
|
-
control: (base) => ({
|
|
559
|
-
...base,
|
|
560
|
-
border: 'none',
|
|
561
|
-
boxShadow: 'none',
|
|
562
|
-
backgroundColor: 'transparent',
|
|
563
|
-
minHeight: 'unset',
|
|
564
|
-
}),
|
|
565
|
-
valueContainer: (base) => ({
|
|
566
|
-
...base,
|
|
567
|
-
padding: '0 4px',
|
|
568
|
-
}),
|
|
569
|
-
multiValue: (base) => ({
|
|
570
|
-
...base,
|
|
571
|
-
margin: '1px 2px',
|
|
572
|
-
}),
|
|
573
|
-
multiValueLabel: (base) => ({
|
|
574
|
-
...base,
|
|
575
|
-
fontSize: '12px',
|
|
576
|
-
padding: '1px 4px',
|
|
577
|
-
}),
|
|
578
|
-
placeholder: (base) => ({
|
|
579
|
-
...base,
|
|
580
|
-
color: '#777',
|
|
581
|
-
}),
|
|
582
|
-
indicatorSeparator: () => ({ display: 'none' }),
|
|
583
|
-
dropdownIndicator: (base) => ({
|
|
584
|
-
...base,
|
|
585
|
-
padding: '0 4px',
|
|
586
|
-
}),
|
|
587
|
-
}}
|
|
588
|
-
onChange={(selected) => {
|
|
589
|
-
if (
|
|
590
|
-
column.update?.key &&
|
|
591
|
-
column.update?.url &&
|
|
592
|
-
column.update?.method
|
|
593
|
-
) {
|
|
594
|
-
handleDropdownUpdate(
|
|
595
|
-
column.update.key,
|
|
596
|
-
`${column.update.url}/${data.id}`,
|
|
597
|
-
column.update.method,
|
|
598
|
-
(selected || []).map((s) => s.value)
|
|
599
|
-
);
|
|
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
|
|
600
746
|
}
|
|
601
|
-
|
|
602
|
-
|
|
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>
|
|
603
772
|
);
|
|
604
773
|
},
|
|
605
774
|
};
|