@visns-studio/visns-components 5.25.0 → 5.26.1
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.1",
|
|
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
|
@@ -9,7 +9,7 @@ import moment from 'moment';
|
|
|
9
9
|
import parse from 'html-react-parser';
|
|
10
10
|
import _ from 'lodash';
|
|
11
11
|
import ReactDataGrid from '@visns-studio/visns-datagrid-enterprise';
|
|
12
|
-
import { X } from 'lucide-react';
|
|
12
|
+
import { Info, X } from 'lucide-react';
|
|
13
13
|
import imageCompression from 'browser-image-compression';
|
|
14
14
|
import { confirmDialog } from './utils/ConfirmDialog';
|
|
15
15
|
|
|
@@ -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 = '';
|
|
@@ -3328,45 +3376,128 @@ function Form({
|
|
|
3328
3376
|
</div>
|
|
3329
3377
|
);
|
|
3330
3378
|
} else if (formType === 'update') {
|
|
3379
|
+
const saveAnotherDisableWhen =
|
|
3380
|
+
formSettings.update?.saveAnother
|
|
3381
|
+
?.disableWhen;
|
|
3382
|
+
const saveAnotherDisabled =
|
|
3383
|
+
saveAnotherDisableWhen
|
|
3384
|
+
? evaluateDisableWhen(
|
|
3385
|
+
saveAnotherDisableWhen,
|
|
3386
|
+
formData
|
|
3387
|
+
)
|
|
3388
|
+
: false;
|
|
3389
|
+
|
|
3331
3390
|
return (
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
<button
|
|
3336
|
-
className={styles.btn}
|
|
3337
|
-
onClick={handleSubmit}
|
|
3338
|
-
data-type="save"
|
|
3391
|
+
<>
|
|
3392
|
+
<div
|
|
3393
|
+
className={`${styles.formItem} ${styles.fwItem} ${styles.buttonContainer}`}
|
|
3339
3394
|
>
|
|
3340
|
-
Save & Close
|
|
3341
|
-
</button>
|
|
3342
|
-
{formSettings.update?.hasOwnProperty(
|
|
3343
|
-
'saveAnother'
|
|
3344
|
-
) && (
|
|
3345
3395
|
<button
|
|
3346
3396
|
className={styles.btn}
|
|
3347
3397
|
onClick={handleSubmit}
|
|
3348
|
-
data-type="
|
|
3398
|
+
data-type="save"
|
|
3349
3399
|
>
|
|
3350
|
-
|
|
3351
|
-
.saveAnother
|
|
3352
|
-
?.label ||
|
|
3353
|
-
'Save & Create Another'}
|
|
3400
|
+
Save & Close
|
|
3354
3401
|
</button>
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3402
|
+
{formSettings.update?.hasOwnProperty(
|
|
3403
|
+
'saveAnother'
|
|
3404
|
+
) && (
|
|
3405
|
+
<button
|
|
3406
|
+
className={
|
|
3407
|
+
styles.btn
|
|
3408
|
+
}
|
|
3409
|
+
onClick={
|
|
3410
|
+
handleSubmit
|
|
3411
|
+
}
|
|
3412
|
+
data-type="saveAnother"
|
|
3413
|
+
disabled={
|
|
3414
|
+
saveAnotherDisabled
|
|
3415
|
+
}
|
|
3416
|
+
style={
|
|
3417
|
+
saveAnotherDisabled
|
|
3418
|
+
? {
|
|
3419
|
+
opacity: 0.5,
|
|
3420
|
+
cursor: 'not-allowed',
|
|
3421
|
+
}
|
|
3422
|
+
: undefined
|
|
3423
|
+
}
|
|
3424
|
+
>
|
|
3425
|
+
{formSettings
|
|
3426
|
+
.update
|
|
3427
|
+
.saveAnother
|
|
3428
|
+
?.label ||
|
|
3429
|
+
'Save & Create Another'}
|
|
3430
|
+
</button>
|
|
3431
|
+
)}
|
|
3432
|
+
{formSettings.update?.hasOwnProperty(
|
|
3433
|
+
'saveExit'
|
|
3434
|
+
) && (
|
|
3435
|
+
<button
|
|
3436
|
+
className={
|
|
3437
|
+
styles.btn
|
|
3438
|
+
}
|
|
3439
|
+
onClick={
|
|
3440
|
+
handleSubmit
|
|
3441
|
+
}
|
|
3442
|
+
data-type="saveExit"
|
|
3443
|
+
>
|
|
3444
|
+
{formSettings
|
|
3445
|
+
.update
|
|
3446
|
+
.saveExit
|
|
3447
|
+
?.label ||
|
|
3448
|
+
'Save & Exit'}
|
|
3449
|
+
</button>
|
|
3450
|
+
)}
|
|
3451
|
+
</div>
|
|
3452
|
+
{saveAnotherDisabled && (
|
|
3453
|
+
/* Always-visible note (hover
|
|
3454
|
+
tooltips don't exist on
|
|
3455
|
+
tablets) explaining why the
|
|
3456
|
+
button is disabled */
|
|
3457
|
+
<div
|
|
3458
|
+
className={`${styles.formItem} ${styles.fwItem}`}
|
|
3459
|
+
style={{
|
|
3460
|
+
display: 'flex',
|
|
3461
|
+
justifyContent:
|
|
3462
|
+
'flex-end',
|
|
3463
|
+
}}
|
|
3363
3464
|
>
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3465
|
+
<div
|
|
3466
|
+
style={{
|
|
3467
|
+
display: 'flex',
|
|
3468
|
+
alignItems:
|
|
3469
|
+
'center',
|
|
3470
|
+
gap: '8px',
|
|
3471
|
+
fontSize:
|
|
3472
|
+
'0.85rem',
|
|
3473
|
+
color: '#1e40af',
|
|
3474
|
+
backgroundColor:
|
|
3475
|
+
'#eff6ff',
|
|
3476
|
+
border: '1px solid #bfdbfe',
|
|
3477
|
+
borderRadius:
|
|
3478
|
+
'6px',
|
|
3479
|
+
padding:
|
|
3480
|
+
'8px 12px',
|
|
3481
|
+
marginTop:
|
|
3482
|
+
'8px',
|
|
3483
|
+
maxWidth:
|
|
3484
|
+
'100%',
|
|
3485
|
+
}}
|
|
3486
|
+
>
|
|
3487
|
+
<Info
|
|
3488
|
+
size={16}
|
|
3489
|
+
style={{
|
|
3490
|
+
flexShrink: 0,
|
|
3491
|
+
}}
|
|
3492
|
+
/>
|
|
3493
|
+
<span>
|
|
3494
|
+
{saveAnotherDisableWhen?.message ||
|
|
3495
|
+
'This action is currently unavailable.'}
|
|
3496
|
+
</span>
|
|
3497
|
+
</div>
|
|
3498
|
+
</div>
|
|
3368
3499
|
)}
|
|
3369
|
-
|
|
3500
|
+
</>
|
|
3370
3501
|
);
|
|
3371
3502
|
} else {
|
|
3372
3503
|
return (
|
|
@@ -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([]);
|
|
@@ -311,6 +312,7 @@ function MultiSelect({
|
|
|
311
312
|
isSearchable={isSearchable}
|
|
312
313
|
isMulti={multi}
|
|
313
314
|
isCreatable={isCreatable}
|
|
315
|
+
isDisabled={isDisabled}
|
|
314
316
|
filterOption={createFilter({ ignoreAccents: false })}
|
|
315
317
|
components={{
|
|
316
318
|
SelectList,
|
|
@@ -2689,6 +2689,10 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2689
2689
|
|
|
2690
2690
|
// Create an array of squares based on the stage data
|
|
2691
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';
|
|
2692
2696
|
for (let i = 0; i < stageCount; i++) {
|
|
2693
2697
|
// Get the stage item
|
|
2694
2698
|
const stageItem = stageData[i];
|
|
@@ -2711,21 +2715,27 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2711
2715
|
typeof rawValue === 'object' &&
|
|
2712
2716
|
rawValue.name
|
|
2713
2717
|
) {
|
|
2714
|
-
labelValue =
|
|
2718
|
+
labelValue = useFullLabel
|
|
2719
|
+
? rawValue.name
|
|
2720
|
+
: rawValue.name.charAt(0);
|
|
2715
2721
|
}
|
|
2716
2722
|
// If it's a string, use the first character
|
|
2717
2723
|
else if (
|
|
2718
2724
|
typeof rawValue === 'string' &&
|
|
2719
2725
|
rawValue.length > 0
|
|
2720
2726
|
) {
|
|
2721
|
-
labelValue =
|
|
2727
|
+
labelValue = useFullLabel
|
|
2728
|
+
? rawValue
|
|
2729
|
+
: rawValue.charAt(0);
|
|
2722
2730
|
}
|
|
2723
2731
|
// Otherwise use the raw value if it can be converted to string
|
|
2724
2732
|
else if (
|
|
2725
2733
|
rawValue !== null &&
|
|
2726
2734
|
rawValue !== undefined
|
|
2727
2735
|
) {
|
|
2728
|
-
labelValue =
|
|
2736
|
+
labelValue = useFullLabel
|
|
2737
|
+
? String(rawValue)
|
|
2738
|
+
: String(rawValue).charAt(0);
|
|
2729
2739
|
}
|
|
2730
2740
|
}
|
|
2731
2741
|
|
|
@@ -2754,6 +2764,18 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2754
2764
|
}
|
|
2755
2765
|
}
|
|
2756
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
|
+
|
|
2757
2779
|
// Determine the background color based on stageColour configuration
|
|
2758
2780
|
let backgroundColor = 'var(--primary-color, #4a90e2)';
|
|
2759
2781
|
|
|
@@ -2811,7 +2833,9 @@ export const renderStageCounterColumn = ({ column, commonProps, navigate, onUpda
|
|
|
2811
2833
|
|
|
2812
2834
|
// Apply visual feedback for inactive stages
|
|
2813
2835
|
const stageStyle = {
|
|
2814
|
-
width: '20px',
|
|
2836
|
+
width: useFullLabel ? 'auto' : '20px',
|
|
2837
|
+
minWidth: '20px',
|
|
2838
|
+
padding: useFullLabel ? '0 6px' : '0',
|
|
2815
2839
|
height: '20px',
|
|
2816
2840
|
backgroundColor: backgroundColor,
|
|
2817
2841
|
borderRadius: '3px',
|