@visns-studio/visns-components 5.25.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.25.0",
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
@@ -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}
@@ -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
- <button
3346
- className={styles.btn}
3347
- onClick={handleSubmit}
3348
- data-type="saveAnother"
3349
- >
3350
- {formSettings.update
3351
- .saveAnother
3352
- ?.label ||
3353
- 'Save & Create Another'}
3354
- </button>
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([]);
@@ -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 = rawValue.name.charAt(0);
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 = rawValue.charAt(0);
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 = String(rawValue).charAt(0);
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',