@visns-studio/visns-components 5.24.0 → 5.26.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.26.0",
|
|
98
98
|
"description": "Various packages to assist in the development of our Custom Applications.",
|
|
99
99
|
"main": "src/index.js",
|
|
100
100
|
"files": [
|
|
@@ -530,6 +530,15 @@ const DataGrid = forwardRef(
|
|
|
530
530
|
if (gridRef.current?.api) {
|
|
531
531
|
gridRef.current.api.forceUpdate();
|
|
532
532
|
}
|
|
533
|
+
|
|
534
|
+
// Restore the scroll position captured by handleReload,
|
|
535
|
+
// after forceUpdate so the re-render can't clobber it
|
|
536
|
+
const pending = pendingScrollRestoreRef.current;
|
|
537
|
+
if (pending && gridRef.current) {
|
|
538
|
+
pendingScrollRestoreRef.current = null;
|
|
539
|
+
gridRef.current.setScrollTop?.(pending.top);
|
|
540
|
+
gridRef.current.setScrollLeft?.(pending.left);
|
|
541
|
+
}
|
|
533
542
|
}, 50); // Increased delay to ensure proper rendering consistency
|
|
534
543
|
});
|
|
535
544
|
|
|
@@ -545,6 +554,8 @@ const DataGrid = forwardRef(
|
|
|
545
554
|
const [gridColumns, setGridColumns] = useState([]);
|
|
546
555
|
const [columnsMetadata, setColumnsMetadata] = useState({});
|
|
547
556
|
const gridRef = useRef(null);
|
|
557
|
+
// Scroll position waiting to be re-applied after a data reload
|
|
558
|
+
const pendingScrollRestoreRef = useRef(null);
|
|
548
559
|
|
|
549
560
|
// Get default limit from userProfile preferences, tableSetting, or default to 25
|
|
550
561
|
const getDefaultLimit = () => {
|
|
@@ -1388,6 +1399,19 @@ const DataGrid = forwardRef(
|
|
|
1388
1399
|
};
|
|
1389
1400
|
|
|
1390
1401
|
const handleReload = () => {
|
|
1402
|
+
// Preserve the user's scroll position across the reload: the
|
|
1403
|
+
// datagrid resets to the top when its dataSource resolves, so
|
|
1404
|
+
// capture here and restore once the new data has rendered
|
|
1405
|
+
// (see the dataSource callback).
|
|
1406
|
+
const scrollTop = gridRef.current?.getScrollTop?.() ?? 0;
|
|
1407
|
+
const scrollLeft = gridRef.current?.getScrollLeft?.() ?? 0;
|
|
1408
|
+
if (scrollTop > 0 || scrollLeft > 0) {
|
|
1409
|
+
pendingScrollRestoreRef.current = {
|
|
1410
|
+
top: scrollTop,
|
|
1411
|
+
left: scrollLeft,
|
|
1412
|
+
};
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1391
1415
|
gridRef.current.paginationProps.reload();
|
|
1392
1416
|
|
|
1393
1417
|
// Force grid to recalculate row heights after reload
|
package/src/components/Field.jsx
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
Minus,
|
|
23
23
|
ArrowLeft,
|
|
24
24
|
Trash2,
|
|
25
|
+
TriangleAlert,
|
|
25
26
|
X,
|
|
26
27
|
Lightbulb,
|
|
27
28
|
} from 'lucide-react';
|
|
@@ -75,6 +76,41 @@ function insertVariableAtCursor(el, token) {
|
|
|
75
76
|
});
|
|
76
77
|
}
|
|
77
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Interpolate {placeholders} in a conflict warning template with values
|
|
81
|
+
* from the in-use info returned by the field's `conflict.url` endpoint.
|
|
82
|
+
*/
|
|
83
|
+
function formatConflictMessage(template, info) {
|
|
84
|
+
return template.replace(/\{(\w+)\}/g, (match, key) => info[key] ?? '');
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Evaluate the opt-in `readOnlyWhen` field setting against the form data:
|
|
89
|
+
* an array of {id, value, operator} rules; the field is read-only when ANY
|
|
90
|
+
* rule matches. Operators: eq (default), neq, notEmpty.
|
|
91
|
+
*/
|
|
92
|
+
function evaluateReadOnlyWhen(rules, data) {
|
|
93
|
+
if (!Array.isArray(rules) || rules.length === 0) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return rules.some((rule) => {
|
|
98
|
+
const value = data?.[rule.id];
|
|
99
|
+
|
|
100
|
+
switch (rule.operator) {
|
|
101
|
+
case 'neq':
|
|
102
|
+
return value != rule.value;
|
|
103
|
+
case 'notEmpty':
|
|
104
|
+
return Array.isArray(value)
|
|
105
|
+
? value.length > 0
|
|
106
|
+
: value != null && value !== '';
|
|
107
|
+
case 'eq':
|
|
108
|
+
default:
|
|
109
|
+
return value == rule.value;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
78
114
|
function Field({
|
|
79
115
|
api,
|
|
80
116
|
autocompleteCallback,
|
|
@@ -122,6 +158,165 @@ function Field({
|
|
|
122
158
|
const [emailSuggestions, setEmailSuggestions] = useState([]);
|
|
123
159
|
const [showEmailSuggestions, setShowEmailSuggestions] = useState(false);
|
|
124
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Conflict warnings (opt-in via `settings.conflict`): the field polls
|
|
163
|
+
* `conflict.url` for a map of {optionId: info} describing options that
|
|
164
|
+
* are currently in use elsewhere, warns when a selected option appears
|
|
165
|
+
* in that map, and toasts when a busy option is newly selected. Inert
|
|
166
|
+
* when the setting is absent.
|
|
167
|
+
*/
|
|
168
|
+
const [conflictMap, setConflictMap] = useState({});
|
|
169
|
+
// null = first run; skips the assignment-time toast for values that
|
|
170
|
+
// were already selected when the form opened
|
|
171
|
+
const prevConflictSelectionRef = useRef(null);
|
|
172
|
+
|
|
173
|
+
// Opt-in conditional read-only (e.g. lock the header selector once an
|
|
174
|
+
// item has entered Pickling); inert when `readOnlyWhen` is absent
|
|
175
|
+
const isFieldReadOnly =
|
|
176
|
+
settings.readOnly === true ||
|
|
177
|
+
evaluateReadOnlyWhen(settings.readOnlyWhen, formData);
|
|
178
|
+
const conflictExcludeValue =
|
|
179
|
+
settings.conflict?.excludeKey != null
|
|
180
|
+
? formData?.[settings.conflict.excludeKey]
|
|
181
|
+
: undefined;
|
|
182
|
+
|
|
183
|
+
useEffect(() => {
|
|
184
|
+
const conflict = settings.conflict;
|
|
185
|
+
|
|
186
|
+
if (
|
|
187
|
+
!conflict?.url ||
|
|
188
|
+
!['multi-dropdown', 'multi-dropdown-ajax'].includes(settings.type)
|
|
189
|
+
) {
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
let active = true;
|
|
194
|
+
|
|
195
|
+
const payload = {};
|
|
196
|
+
if (conflict.excludeParam && conflictExcludeValue != null) {
|
|
197
|
+
payload[conflict.excludeParam] = conflictExcludeValue;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const fetchConflicts = async () => {
|
|
201
|
+
try {
|
|
202
|
+
const res = await CustomFetch(conflict.url, 'POST', payload);
|
|
203
|
+
|
|
204
|
+
if (active && res?.data && res.data.error === '') {
|
|
205
|
+
setConflictMap(res.data.data || {});
|
|
206
|
+
}
|
|
207
|
+
} catch (err) {
|
|
208
|
+
console.error(err);
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
fetchConflicts();
|
|
213
|
+
|
|
214
|
+
// Refresh while the field is mounted so the warning tracks what is
|
|
215
|
+
// actually happening on the floor without a manual reload
|
|
216
|
+
const intervalId = setInterval(
|
|
217
|
+
fetchConflicts,
|
|
218
|
+
(conflict.interval ?? 5) * 1000
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
return () => {
|
|
222
|
+
active = false;
|
|
223
|
+
clearInterval(intervalId);
|
|
224
|
+
};
|
|
225
|
+
}, [settings.conflict?.url, settings.type, conflictExcludeValue]);
|
|
226
|
+
|
|
227
|
+
const conflictWarnings = useMemo(() => {
|
|
228
|
+
const conflict = settings.conflict;
|
|
229
|
+
|
|
230
|
+
if (!conflict || !Array.isArray(inputValue)) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return inputValue
|
|
235
|
+
.filter(
|
|
236
|
+
(item) =>
|
|
237
|
+
item && conflictMap[String(item.id ?? item.value)]
|
|
238
|
+
)
|
|
239
|
+
.map((item) => {
|
|
240
|
+
const info = conflictMap[String(item.id ?? item.value)];
|
|
241
|
+
|
|
242
|
+
return formatConflictMessage(
|
|
243
|
+
conflict.message ||
|
|
244
|
+
'"{label}" is already in use on Job #{job_number} ({customer})',
|
|
245
|
+
{
|
|
246
|
+
...info,
|
|
247
|
+
label:
|
|
248
|
+
item.label ?? item.name ?? info.label ?? '',
|
|
249
|
+
}
|
|
250
|
+
);
|
|
251
|
+
});
|
|
252
|
+
}, [settings.conflict, inputValue, conflictMap]);
|
|
253
|
+
|
|
254
|
+
useEffect(() => {
|
|
255
|
+
if (!settings.conflict || !Array.isArray(inputValue)) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const selectedIds = inputValue
|
|
260
|
+
.filter((item) => item)
|
|
261
|
+
.map((item) => String(item.id ?? item.value));
|
|
262
|
+
|
|
263
|
+
if (prevConflictSelectionRef.current === null) {
|
|
264
|
+
prevConflictSelectionRef.current = selectedIds;
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const added = selectedIds.filter(
|
|
269
|
+
(id) => !prevConflictSelectionRef.current.includes(id)
|
|
270
|
+
);
|
|
271
|
+
prevConflictSelectionRef.current = selectedIds;
|
|
272
|
+
|
|
273
|
+
added.forEach((id) => {
|
|
274
|
+
const info = conflictMap[id];
|
|
275
|
+
|
|
276
|
+
if (info) {
|
|
277
|
+
toast.warning(
|
|
278
|
+
formatConflictMessage(
|
|
279
|
+
settings.conflict.message ||
|
|
280
|
+
'"{label}" is already in use on Job #{job_number} ({customer})',
|
|
281
|
+
info
|
|
282
|
+
)
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}, [inputValue, conflictMap, settings.conflict]);
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Surface the in-use state inside the dropdown menu itself: MultiSelect
|
|
290
|
+
* already renders an option `description`, so busy options get one.
|
|
291
|
+
* Pass-through when no conflict setting or nothing is busy.
|
|
292
|
+
*/
|
|
293
|
+
const decorateOptionsWithConflicts = (list) => {
|
|
294
|
+
if (
|
|
295
|
+
!settings.conflict ||
|
|
296
|
+
!Array.isArray(list) ||
|
|
297
|
+
Object.keys(conflictMap).length === 0
|
|
298
|
+
) {
|
|
299
|
+
return list;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return list.map((option) => {
|
|
303
|
+
const info = conflictMap[String(option.id ?? option.value)];
|
|
304
|
+
|
|
305
|
+
if (!info) {
|
|
306
|
+
return option;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
...option,
|
|
311
|
+
description: formatConflictMessage(
|
|
312
|
+
settings.conflict.optionMessage ||
|
|
313
|
+
'In use — Job #{job_number} ({customer})',
|
|
314
|
+
info
|
|
315
|
+
),
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
};
|
|
319
|
+
|
|
125
320
|
/** Canvas States */
|
|
126
321
|
const [canvasPopupOpen, setCanvasPopupOpen] = useState(false);
|
|
127
322
|
const [canvasUrl, setCanvasUrl] = useState('');
|
|
@@ -1918,13 +2113,14 @@ function Field({
|
|
|
1918
2113
|
? ''
|
|
1919
2114
|
: inputValue
|
|
1920
2115
|
}
|
|
1921
|
-
options={
|
|
2116
|
+
options={decorateOptionsWithConflicts(
|
|
1922
2117
|
settings.type === 'multi-dropdown'
|
|
1923
2118
|
? settings.options
|
|
1924
2119
|
: options
|
|
1925
|
-
}
|
|
2120
|
+
)}
|
|
1926
2121
|
isSearchable={settings.isSearchable ?? true}
|
|
1927
2122
|
isCreatable={settings.isCreatable ?? false}
|
|
2123
|
+
isDisabled={isFieldReadOnly}
|
|
1928
2124
|
creatableConfig={settings.creatableConfig || {}}
|
|
1929
2125
|
dataOptions={dataOptions}
|
|
1930
2126
|
style={style}
|
|
@@ -3224,6 +3420,39 @@ function Field({
|
|
|
3224
3420
|
Tip: Type to create new entries and press Enter
|
|
3225
3421
|
</div>
|
|
3226
3422
|
)}
|
|
3423
|
+
{conflictWarnings.length > 0 && (
|
|
3424
|
+
<div
|
|
3425
|
+
style={{
|
|
3426
|
+
display: 'flex',
|
|
3427
|
+
flexDirection: 'column',
|
|
3428
|
+
gap: '4px',
|
|
3429
|
+
marginTop: '4px',
|
|
3430
|
+
}}
|
|
3431
|
+
>
|
|
3432
|
+
{conflictWarnings.map((message, index) => (
|
|
3433
|
+
<div
|
|
3434
|
+
key={index}
|
|
3435
|
+
style={{
|
|
3436
|
+
fontSize: '0.8rem',
|
|
3437
|
+
color: '#92400e',
|
|
3438
|
+
backgroundColor: '#fef3c7',
|
|
3439
|
+
border: '1px solid #f59e0b',
|
|
3440
|
+
borderRadius: '4px',
|
|
3441
|
+
padding: '4px 8px',
|
|
3442
|
+
display: 'flex',
|
|
3443
|
+
alignItems: 'center',
|
|
3444
|
+
gap: '6px',
|
|
3445
|
+
}}
|
|
3446
|
+
>
|
|
3447
|
+
<TriangleAlert
|
|
3448
|
+
size={14}
|
|
3449
|
+
style={{ flexShrink: 0 }}
|
|
3450
|
+
/>
|
|
3451
|
+
<span>{message}</span>
|
|
3452
|
+
</div>
|
|
3453
|
+
))}
|
|
3454
|
+
</div>
|
|
3455
|
+
)}
|
|
3227
3456
|
{renderAdditionalContainer()}
|
|
3228
3457
|
<StandardModal
|
|
3229
3458
|
isOpen={childFormShow}
|
package/src/components/Form.jsx
CHANGED
|
@@ -65,6 +65,32 @@ const optimizeImageIfNeeded = async (file) => {
|
|
|
65
65
|
return file;
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
/**
|
|
69
|
+
* Evaluate an opt-in `disableWhen` rule ({id, value, operator, message})
|
|
70
|
+
* against the form data. Used by save buttons (e.g. saveAnother) to disable
|
|
71
|
+
* themselves conditionally. Operators: eq (default), notEmpty,
|
|
72
|
+
* arrayLengthGte (value = minimum length).
|
|
73
|
+
*/
|
|
74
|
+
const evaluateDisableWhen = (rule, data) => {
|
|
75
|
+
if (!rule || !rule.id) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const value = data?.[rule.id];
|
|
80
|
+
|
|
81
|
+
switch (rule.operator) {
|
|
82
|
+
case 'arrayLengthGte':
|
|
83
|
+
return Array.isArray(value) && value.length >= (rule.value ?? 0);
|
|
84
|
+
case 'notEmpty':
|
|
85
|
+
return Array.isArray(value)
|
|
86
|
+
? value.length > 0
|
|
87
|
+
: value != null && value !== '';
|
|
88
|
+
case 'eq':
|
|
89
|
+
default:
|
|
90
|
+
return value == rule.value;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
|
|
68
94
|
function Form({
|
|
69
95
|
ajaxSetting,
|
|
70
96
|
api,
|
|
@@ -1149,6 +1175,28 @@ function Form({
|
|
|
1149
1175
|
e.preventDefault();
|
|
1150
1176
|
|
|
1151
1177
|
const { type } = e.target.dataset;
|
|
1178
|
+
|
|
1179
|
+
// Guard: a conditionally-disabled saveAnother must not run
|
|
1180
|
+
// even if a stale render left the button clickable
|
|
1181
|
+
if (type === 'saveAnother') {
|
|
1182
|
+
const saveAnotherSettings =
|
|
1183
|
+
formType === 'update'
|
|
1184
|
+
? formSettings.update?.saveAnother
|
|
1185
|
+
: formSettings.create?.saveAnother;
|
|
1186
|
+
const disableWhen = saveAnotherSettings?.disableWhen;
|
|
1187
|
+
|
|
1188
|
+
if (
|
|
1189
|
+
disableWhen &&
|
|
1190
|
+
evaluateDisableWhen(disableWhen, formData)
|
|
1191
|
+
) {
|
|
1192
|
+
toast.warning(
|
|
1193
|
+
disableWhen.message ||
|
|
1194
|
+
'This action is currently unavailable.'
|
|
1195
|
+
);
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1152
1200
|
let fields = formSettings.fields;
|
|
1153
1201
|
let _inputClass = inputClass;
|
|
1154
1202
|
let validation = '';
|
|
@@ -3341,18 +3389,55 @@ function Form({
|
|
|
3341
3389
|
</button>
|
|
3342
3390
|
{formSettings.update?.hasOwnProperty(
|
|
3343
3391
|
'saveAnother'
|
|
3344
|
-
) &&
|
|
3345
|
-
|
|
3346
|
-
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3392
|
+
) &&
|
|
3393
|
+
(() => {
|
|
3394
|
+
const disableWhen =
|
|
3395
|
+
formSettings.update
|
|
3396
|
+
.saveAnother
|
|
3397
|
+
?.disableWhen;
|
|
3398
|
+
const isDisabled =
|
|
3399
|
+
disableWhen
|
|
3400
|
+
? evaluateDisableWhen(
|
|
3401
|
+
disableWhen,
|
|
3402
|
+
formData
|
|
3403
|
+
)
|
|
3404
|
+
: false;
|
|
3405
|
+
|
|
3406
|
+
return (
|
|
3407
|
+
<button
|
|
3408
|
+
className={
|
|
3409
|
+
styles.btn
|
|
3410
|
+
}
|
|
3411
|
+
onClick={
|
|
3412
|
+
handleSubmit
|
|
3413
|
+
}
|
|
3414
|
+
data-type="saveAnother"
|
|
3415
|
+
disabled={
|
|
3416
|
+
isDisabled
|
|
3417
|
+
}
|
|
3418
|
+
title={
|
|
3419
|
+
isDisabled
|
|
3420
|
+
? disableWhen?.message ||
|
|
3421
|
+
''
|
|
3422
|
+
: undefined
|
|
3423
|
+
}
|
|
3424
|
+
style={
|
|
3425
|
+
isDisabled
|
|
3426
|
+
? {
|
|
3427
|
+
opacity: 0.5,
|
|
3428
|
+
cursor: 'not-allowed',
|
|
3429
|
+
}
|
|
3430
|
+
: undefined
|
|
3431
|
+
}
|
|
3432
|
+
>
|
|
3433
|
+
{formSettings
|
|
3434
|
+
.update
|
|
3435
|
+
.saveAnother
|
|
3436
|
+
?.label ||
|
|
3437
|
+
'Save & Create Another'}
|
|
3438
|
+
</button>
|
|
3439
|
+
);
|
|
3440
|
+
})()}
|
|
3356
3441
|
{formSettings.update?.hasOwnProperty(
|
|
3357
3442
|
'saveExit'
|
|
3358
3443
|
) && (
|
|
@@ -144,6 +144,7 @@ function MultiSelect({
|
|
|
144
144
|
style,
|
|
145
145
|
isCreatable = false,
|
|
146
146
|
isSearchable = true,
|
|
147
|
+
isDisabled = false,
|
|
147
148
|
creatableConfig = {},
|
|
148
149
|
}) {
|
|
149
150
|
const [selectOptions, setSelectOptions] = useState([]);
|
|
@@ -249,23 +250,37 @@ function MultiSelect({
|
|
|
249
250
|
|
|
250
251
|
const handleCreate = async (inputValue) => {
|
|
251
252
|
if (creatableConfig.url && creatableConfig.method) {
|
|
253
|
+
// creatableConfig.payload lets the consumer send extra fields
|
|
254
|
+
// with the create (e.g. {is_active: 1}); creatableConfig.field
|
|
255
|
+
// overrides the body key for the typed value (default "label",
|
|
256
|
+
// e.g. "name" for entities whose label column is `name`).
|
|
257
|
+
const valueKey = creatableConfig.field || 'label';
|
|
252
258
|
const res = await CustomFetch(
|
|
253
259
|
creatableConfig.url,
|
|
254
260
|
creatableConfig.method,
|
|
255
|
-
{
|
|
261
|
+
{ [valueKey]: inputValue, ...(creatableConfig.payload || {}) }
|
|
256
262
|
);
|
|
257
263
|
|
|
258
|
-
|
|
264
|
+
const created = res?.data?.data ?? res?.data;
|
|
265
|
+
if (res && created && created.id) {
|
|
259
266
|
const newOption = {
|
|
260
|
-
value:
|
|
261
|
-
label:
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
...res.data,
|
|
267
|
+
value: created.id,
|
|
268
|
+
label: created.label || created.name || inputValue,
|
|
269
|
+
description: created.description || null,
|
|
270
|
+
...created,
|
|
265
271
|
};
|
|
266
272
|
|
|
273
|
+
const nextValue = [...selectValue, newOption];
|
|
267
274
|
setSelectOptions((prevOptions) => [...prevOptions, newOption]);
|
|
268
|
-
setSelectValue(
|
|
275
|
+
setSelectValue(nextValue);
|
|
276
|
+
// Propagate to the parent form so the freshly-created option
|
|
277
|
+
// is part of the field value and is persisted on save —
|
|
278
|
+
// updating local state alone left it out of the submission.
|
|
279
|
+
onChange(
|
|
280
|
+
nextValue,
|
|
281
|
+
{ action: 'create-option', option: newOption },
|
|
282
|
+
settings.id
|
|
283
|
+
);
|
|
269
284
|
}
|
|
270
285
|
}
|
|
271
286
|
};
|
|
@@ -297,6 +312,7 @@ function MultiSelect({
|
|
|
297
312
|
isSearchable={isSearchable}
|
|
298
313
|
isMulti={multi}
|
|
299
314
|
isCreatable={isCreatable}
|
|
315
|
+
isDisabled={isDisabled}
|
|
300
316
|
filterOption={createFilter({ ignoreAccents: false })}
|
|
301
317
|
components={{
|
|
302
318
|
SelectList,
|
|
@@ -731,9 +731,66 @@ export const renderMultiDropdownColumn = ({
|
|
|
731
731
|
).toLowerCase()
|
|
732
732
|
);
|
|
733
733
|
|
|
734
|
+
const save = (ids) => {
|
|
735
|
+
if (
|
|
736
|
+
column.update?.key &&
|
|
737
|
+
column.update?.url &&
|
|
738
|
+
column.update?.method
|
|
739
|
+
) {
|
|
740
|
+
handleDropdownUpdate(
|
|
741
|
+
column.update.key,
|
|
742
|
+
`${column.update.url}/${data.id}`,
|
|
743
|
+
column.update.method,
|
|
744
|
+
ids
|
|
745
|
+
);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// creatable: typing a value not in the list offers "Add …"
|
|
750
|
+
// which POSTs a new entry to the source entity and adds it to
|
|
751
|
+
// this row's selection.
|
|
752
|
+
const handleCreate = async (inputValue) => {
|
|
753
|
+
const label = String(inputValue ?? '').trim();
|
|
754
|
+
if (
|
|
755
|
+
!label ||
|
|
756
|
+
!column.creatable?.url ||
|
|
757
|
+
!column.creatable?.field
|
|
758
|
+
) {
|
|
759
|
+
return;
|
|
760
|
+
}
|
|
761
|
+
try {
|
|
762
|
+
const res = await CustomFetch(
|
|
763
|
+
column.creatable.url,
|
|
764
|
+
'POST',
|
|
765
|
+
{
|
|
766
|
+
[column.creatable.field]: label,
|
|
767
|
+
...(column.creatable.payload || {}),
|
|
768
|
+
}
|
|
769
|
+
);
|
|
770
|
+
const created = res?.data?.data;
|
|
771
|
+
if (res?.data?.error === '' && created?.id) {
|
|
772
|
+
save([
|
|
773
|
+
...value.map((v) => v.value),
|
|
774
|
+
created.id,
|
|
775
|
+
]);
|
|
776
|
+
} else {
|
|
777
|
+
toast.error(
|
|
778
|
+
res?.data?.error || 'Could not create entry'
|
|
779
|
+
);
|
|
780
|
+
}
|
|
781
|
+
} catch (err) {
|
|
782
|
+
console.error(err);
|
|
783
|
+
toast.error('Could not create entry');
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
const SelectComponent = column.creatable
|
|
788
|
+
? CreatableSelect
|
|
789
|
+
: Select;
|
|
790
|
+
|
|
734
791
|
return (
|
|
735
792
|
<div>
|
|
736
|
-
<
|
|
793
|
+
<SelectComponent
|
|
737
794
|
isMulti
|
|
738
795
|
options={options}
|
|
739
796
|
value={value}
|
|
@@ -746,20 +803,13 @@ export const renderMultiDropdownColumn = ({
|
|
|
746
803
|
}
|
|
747
804
|
menuPlacement="auto"
|
|
748
805
|
styles={compactSelectStyles}
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
)
|
|
755
|
-
|
|
756
|
-
column.update.key,
|
|
757
|
-
`${column.update.url}/${data.id}`,
|
|
758
|
-
column.update.method,
|
|
759
|
-
(selected || []).map((s) => s.value)
|
|
760
|
-
);
|
|
761
|
-
}
|
|
762
|
-
}}
|
|
806
|
+
{...(column.creatable && {
|
|
807
|
+
onCreateOption: handleCreate,
|
|
808
|
+
formatCreateLabel: (v) => `Add "${v}"`,
|
|
809
|
+
})}
|
|
810
|
+
onChange={(selected) =>
|
|
811
|
+
save((selected || []).map((s) => s.value))
|
|
812
|
+
}
|
|
763
813
|
/>
|
|
764
814
|
{otherSelected && (
|
|
765
815
|
<OtherValueInput
|
|
@@ -2639,6 +2689,10 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2639
2689
|
|
|
2640
2690
|
// Create an array of squares based on the stage data
|
|
2641
2691
|
const squares = [];
|
|
2692
|
+
// labelMode 'full' (opt-in) renders the whole label
|
|
2693
|
+
// instead of just its first character
|
|
2694
|
+
const useFullLabel =
|
|
2695
|
+
column.stageConfig.labelMode === 'full';
|
|
2642
2696
|
for (let i = 0; i < stageCount; i++) {
|
|
2643
2697
|
// Get the stage item
|
|
2644
2698
|
const stageItem = stageData[i];
|
|
@@ -2661,21 +2715,27 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2661
2715
|
typeof rawValue === 'object' &&
|
|
2662
2716
|
rawValue.name
|
|
2663
2717
|
) {
|
|
2664
|
-
labelValue =
|
|
2718
|
+
labelValue = useFullLabel
|
|
2719
|
+
? rawValue.name
|
|
2720
|
+
: rawValue.name.charAt(0);
|
|
2665
2721
|
}
|
|
2666
2722
|
// If it's a string, use the first character
|
|
2667
2723
|
else if (
|
|
2668
2724
|
typeof rawValue === 'string' &&
|
|
2669
2725
|
rawValue.length > 0
|
|
2670
2726
|
) {
|
|
2671
|
-
labelValue =
|
|
2727
|
+
labelValue = useFullLabel
|
|
2728
|
+
? rawValue
|
|
2729
|
+
: rawValue.charAt(0);
|
|
2672
2730
|
}
|
|
2673
2731
|
// Otherwise use the raw value if it can be converted to string
|
|
2674
2732
|
else if (
|
|
2675
2733
|
rawValue !== null &&
|
|
2676
2734
|
rawValue !== undefined
|
|
2677
2735
|
) {
|
|
2678
|
-
labelValue =
|
|
2736
|
+
labelValue = useFullLabel
|
|
2737
|
+
? String(rawValue)
|
|
2738
|
+
: String(rawValue).charAt(0);
|
|
2679
2739
|
}
|
|
2680
2740
|
}
|
|
2681
2741
|
|
|
@@ -2704,6 +2764,18 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2704
2764
|
}
|
|
2705
2765
|
}
|
|
2706
2766
|
|
|
2767
|
+
// tooltipKey (opt-in): a stage-item field holding the
|
|
2768
|
+
// full tooltip text (e.g. "Header 12 — Galvanizing")
|
|
2769
|
+
if (
|
|
2770
|
+
column.stageConfig.tooltipKey &&
|
|
2771
|
+
stageItem &&
|
|
2772
|
+
stageItem[column.stageConfig.tooltipKey]
|
|
2773
|
+
) {
|
|
2774
|
+
tooltipContent = String(
|
|
2775
|
+
stageItem[column.stageConfig.tooltipKey]
|
|
2776
|
+
);
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2707
2779
|
// Determine the background color based on stageColour configuration
|
|
2708
2780
|
let backgroundColor = 'var(--primary-color, #4a90e2)';
|
|
2709
2781
|
|
|
@@ -2761,7 +2833,9 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2761
2833
|
|
|
2762
2834
|
// Apply visual feedback for inactive stages
|
|
2763
2835
|
const stageStyle = {
|
|
2764
|
-
width: '20px',
|
|
2836
|
+
width: useFullLabel ? 'auto' : '20px',
|
|
2837
|
+
minWidth: '20px',
|
|
2838
|
+
padding: useFullLabel ? '0 6px' : '0',
|
|
2765
2839
|
height: '20px',
|
|
2766
2840
|
backgroundColor: backgroundColor,
|
|
2767
2841
|
borderRadius: '3px',
|