@visns-studio/visns-components 5.22.0 → 5.25.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.25.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;
@@ -249,23 +249,37 @@ function MultiSelect({
249
249
 
250
250
  const handleCreate = async (inputValue) => {
251
251
  if (creatableConfig.url && creatableConfig.method) {
252
+ // creatableConfig.payload lets the consumer send extra fields
253
+ // with the create (e.g. {is_active: 1}); creatableConfig.field
254
+ // overrides the body key for the typed value (default "label",
255
+ // e.g. "name" for entities whose label column is `name`).
256
+ const valueKey = creatableConfig.field || 'label';
252
257
  const res = await CustomFetch(
253
258
  creatableConfig.url,
254
259
  creatableConfig.method,
255
- { label: inputValue }
260
+ { [valueKey]: inputValue, ...(creatableConfig.payload || {}) }
256
261
  );
257
262
 
258
- if (res && res.data) {
263
+ const created = res?.data?.data ?? res?.data;
264
+ if (res && created && created.id) {
259
265
  const newOption = {
260
- value: res.data.id,
261
- label: res.data.label || inputValue,
262
- // Make sure description is preserved if it exists in the response
263
- description: res.data.description || null,
264
- ...res.data,
266
+ value: created.id,
267
+ label: created.label || created.name || inputValue,
268
+ description: created.description || null,
269
+ ...created,
265
270
  };
266
271
 
272
+ const nextValue = [...selectValue, newOption];
267
273
  setSelectOptions((prevOptions) => [...prevOptions, newOption]);
268
- setSelectValue((prevValues) => [...prevValues, newOption]);
274
+ setSelectValue(nextValue);
275
+ // Propagate to the parent form so the freshly-created option
276
+ // is part of the field value and is persisted on save —
277
+ // updating local state alone left it out of the submission.
278
+ onChange(
279
+ nextValue,
280
+ { action: 'create-option', option: newOption },
281
+ settings.id
282
+ );
269
283
  }
270
284
  }
271
285
  };
@@ -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,107 @@ export const renderMultiDropdownColumn = ({
540
718
  ? String(data[column.fallbackKey])
541
719
  : 'Select...';
542
720
 
543
- 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
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
+
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
+ );
554
780
  }
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
- );
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
+
791
+ return (
792
+ <div>
793
+ <SelectComponent
794
+ isMulti
795
+ options={options}
796
+ value={value}
797
+ placeholder={placeholder}
798
+ isClearable={false}
799
+ menuPortalTarget={
800
+ typeof document !== 'undefined'
801
+ ? document.body
802
+ : undefined
600
803
  }
601
- }}
602
- />
804
+ menuPlacement="auto"
805
+ styles={compactSelectStyles}
806
+ {...(column.creatable && {
807
+ onCreateOption: handleCreate,
808
+ formatCreateLabel: (v) => `Add "${v}"`,
809
+ })}
810
+ onChange={(selected) =>
811
+ save((selected || []).map((s) => s.value))
812
+ }
813
+ />
814
+ {otherSelected && (
815
+ <OtherValueInput
816
+ column={column}
817
+ data={data}
818
+ handleDropdownUpdate={handleDropdownUpdate}
819
+ />
820
+ )}
821
+ </div>
603
822
  );
604
823
  },
605
824
  };