@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.22.0",
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": [
@@ -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
- <Select
545
- isMulti
546
- options={options}
547
- value={value}
548
- placeholder={placeholder}
549
- isClearable={false}
550
- menuPortalTarget={
551
- typeof document !== 'undefined'
552
- ? document.body
553
- : undefined
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
  };