@visns-studio/visns-components 5.21.0 → 5.22.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.21.0",
97
+ "version": "5.22.0",
98
98
  "description": "Various packages to assist in the development of our Custom Applications.",
99
99
  "main": "src/index.js",
100
100
  "files": [
@@ -95,6 +95,7 @@ import {
95
95
  renderArrayCountColumn,
96
96
  renderColourColumn,
97
97
  renderDropdownColumn,
98
+ renderMultiDropdownColumn,
98
99
  renderImageColumn,
99
100
  renderFileColumn,
100
101
  renderIconsColumn,
@@ -2762,7 +2763,7 @@ const DataGrid = forwardRef(
2762
2763
  500
2763
2764
  );
2764
2765
 
2765
- const getDropdownData = async (ids, url, fields, where) => {
2766
+ const getDropdownData = async (ids, url, fields, where, orderBy) => {
2766
2767
  // Concatenate id array into a single string if it's an array
2767
2768
  const uniqueId = Array.isArray(ids) ? ids.join('') : ids;
2768
2769
 
@@ -2772,6 +2773,9 @@ const DataGrid = forwardRef(
2772
2773
  const res = await CustomFetch(url, 'POST', {
2773
2774
  fields,
2774
2775
  where,
2776
+ // e.g. orderBy: 'sort_order' so option order follows
2777
+ // the managed sort instead of the endpoint default
2778
+ ...(orderBy && { orderBy }),
2775
2779
  });
2776
2780
  resolve({ [uniqueId]: res.data.data }); // Correctly use uniqueId as the key
2777
2781
  } catch (err) {
@@ -2783,13 +2787,16 @@ const DataGrid = forwardRef(
2783
2787
 
2784
2788
  useEffect(() => {
2785
2789
  const dropdownFetches = columns
2786
- .filter((column) => column.type === 'dropdown')
2790
+ .filter((column) =>
2791
+ ['dropdown', 'multiDropdown'].includes(column.type)
2792
+ )
2787
2793
  .map((column) =>
2788
2794
  getDropdownData(
2789
2795
  column.id,
2790
2796
  column.url,
2791
2797
  column.fields,
2792
- column.where
2798
+ column.where,
2799
+ column.orderBy
2793
2800
  )
2794
2801
  );
2795
2802
 
@@ -3079,6 +3086,15 @@ const DataGrid = forwardRef(
3079
3086
  dropdownData,
3080
3087
  handleDropdownUpdate,
3081
3088
  });
3089
+ case 'multiDropdown':
3090
+ return renderMultiDropdownColumn({
3091
+ column,
3092
+ commonProps,
3093
+ filterEditor,
3094
+ filterEditorProps,
3095
+ dropdownData,
3096
+ handleDropdownUpdate,
3097
+ });
3082
3098
  case 'file':
3083
3099
  return renderFileColumn({
3084
3100
  column,
@@ -1448,6 +1448,8 @@ function Form({
1448
1448
  let fileUpload = false;
1449
1449
  let fileKey = '';
1450
1450
  let fileFieldSetting = '';
1451
+ const fileKeys = [];
1452
+ const fileFieldSettings = {};
1451
1453
 
1452
1454
  if (
1453
1455
  formSettings.customUrl &&
@@ -1518,11 +1520,14 @@ function Form({
1518
1520
  ) {
1519
1521
  fileUpload = true;
1520
1522
  fileKey = item;
1523
+ fileKeys.push(item);
1521
1524
 
1522
1525
  if (formSettings.fields.length > 0) {
1523
1526
  formSettings.fields.forEach((field) => {
1524
1527
  if (field.id === item) {
1525
1528
  fileFieldSetting = field;
1529
+ fileFieldSettings[item] =
1530
+ field;
1526
1531
  }
1527
1532
  });
1528
1533
  }
@@ -1608,60 +1613,134 @@ function Form({
1608
1613
  updatedFormData['uploadedFiles'] =
1609
1614
  await Promise.all(uploadPromises);
1610
1615
  } else {
1611
- // Optimize image if it's an image file
1612
- const optimizedFile =
1613
- await optimizeImageIfNeeded(
1614
- updatedFormData[fileKey]
1616
+ // Single-File fields. JSON forms can carry
1617
+ // several in one save (e.g. a bio's photo +
1618
+ // document); upload each and attach a
1619
+ // field-scoped descriptor so the backend
1620
+ // stores the file against that exact field.
1621
+ // Non-JSON forms keep the original
1622
+ // one-file-per-save behaviour, driven by the
1623
+ // request-level metadata below.
1624
+ const singleFileKeys = formSettings.json
1625
+ ? fileKeys.filter(
1626
+ (k) =>
1627
+ updatedFormData[k] instanceof
1628
+ File
1629
+ )
1630
+ : [fileKey];
1631
+
1632
+ for (const fieldId of singleFileKeys) {
1633
+ const originalName =
1634
+ updatedFormData[fieldId].name;
1635
+
1636
+ // Optimize image if it's an image file
1637
+ const optimizedFile =
1638
+ await optimizeImageIfNeeded(
1639
+ updatedFormData[fieldId]
1640
+ );
1641
+
1642
+ const toastId = toast.info(
1643
+ `Uploading ${originalName}`,
1644
+ {
1645
+ progress: 0,
1646
+ autoClose: false,
1647
+ }
1615
1648
  );
1616
1649
 
1617
- const toastId = toast.info(
1618
- `Uploading ${updatedFormData[fileKey].name}`,
1619
- {
1620
- progress: 0,
1621
- autoClose: false,
1622
- }
1623
- );
1650
+ const fileRes = await Vapor.store(
1651
+ optimizedFile,
1652
+ {
1653
+ progress: (progress) => {
1654
+ setUploadProgress(
1655
+ progress * 100
1656
+ );
1657
+ toast.update(toastId, {
1658
+ progress,
1659
+ });
1660
+ },
1661
+ }
1662
+ );
1624
1663
 
1625
- const fileRes = await Vapor.store(
1626
- optimizedFile,
1627
- {
1628
- progress: (progress) => {
1629
- setUploadProgress(progress * 100);
1630
- toast.update(toastId, {
1631
- progress,
1632
- });
1633
- },
1664
+ toast.update(toastId, {
1665
+ render: `${originalName} uploaded successfully!`,
1666
+ type: 'success',
1667
+ autoClose: 3000,
1668
+ });
1669
+
1670
+ updatedFormData['uuid'] = fileRes.uuid;
1671
+ updatedFormData['key'] = fileRes.key;
1672
+ updatedFormData['bucket'] = fileRes.bucket;
1673
+ updatedFormData['filename'] = originalName;
1674
+ updatedFormData['filesize'] =
1675
+ optimizedFile.size;
1676
+ updatedFormData['extension'] =
1677
+ fileRes.extension;
1678
+ updatedFormData['file_relationship'] =
1679
+ fieldId;
1680
+ updatedFormData['fileable_field'] = (
1681
+ fileFieldSettings[fieldId] ||
1682
+ fileFieldSetting
1683
+ ).fileable_field;
1684
+
1685
+ if (formSettings.json) {
1686
+ // Replace the raw File (which would
1687
+ // serialise to junk in the JSON
1688
+ // payload) with an upload descriptor
1689
+ // the backend materialises into a
1690
+ // {file_path, file_name, ...} object.
1691
+ updatedFormData[fieldId] = {
1692
+ _vaporUpload: true,
1693
+ uuid: fileRes.uuid,
1694
+ key: fileRes.key,
1695
+ bucket: fileRes.bucket,
1696
+ filename: originalName,
1697
+ filesize: optimizedFile.size,
1698
+ extension: fileRes.extension,
1699
+ };
1634
1700
  }
1635
- );
1636
-
1637
- toast.update(toastId, {
1638
- render: `${updatedFormData[fileKey].name} uploaded successfully!`,
1639
- type: 'success',
1640
- autoClose: 3000,
1641
- });
1642
-
1643
- updatedFormData['uuid'] = fileRes.uuid;
1644
- updatedFormData['key'] = fileRes.key;
1645
- updatedFormData['bucket'] = fileRes.bucket;
1646
- updatedFormData['filename'] =
1647
- updatedFormData[fileKey].name;
1648
- updatedFormData['filesize'] =
1649
- optimizedFile.size;
1650
- updatedFormData['extension'] =
1651
- fileRes.extension;
1652
- updatedFormData['file_relationship'] = fileKey;
1653
- updatedFormData['fileable_field'] =
1654
- fileFieldSetting.fileable_field;
1701
+ }
1655
1702
  }
1656
1703
  }
1657
1704
 
1658
- // Transform keys with dots into nested objects
1705
+ // Transform keys with dots into nested objects.
1706
+ // Walks every dot segment (not just two) so ids like
1707
+ // "transport.visit_before_after.yes_before_other" nest
1708
+ // correctly, and merges plain objects instead of
1709
+ // replacing them so sibling keys written by other
1710
+ // fields on the same path survive regardless of key
1711
+ // order.
1712
+ const isPlainObject = (value) =>
1713
+ value !== null &&
1714
+ typeof value === 'object' &&
1715
+ (value.constructor === Object ||
1716
+ value.constructor === undefined);
1717
+
1659
1718
  let payload = Object.keys(updatedFormData).reduce(
1660
1719
  (acc, key) => {
1661
1720
  if (key.includes('.')) {
1662
- const [root, sub] = key.split('.');
1663
- if (!acc[root]) acc[root] = {};
1664
- acc[root][sub] = updatedFormData[key];
1721
+ const segments = key.split('.');
1722
+ let node = acc;
1723
+
1724
+ for (
1725
+ let i = 0;
1726
+ i < segments.length - 1;
1727
+ i++
1728
+ ) {
1729
+ if (!isPlainObject(node[segments[i]])) {
1730
+ node[segments[i]] = {};
1731
+ }
1732
+ node = node[segments[i]];
1733
+ }
1734
+
1735
+ const last =
1736
+ segments[segments.length - 1];
1737
+ const value = updatedFormData[key];
1738
+
1739
+ node[last] =
1740
+ isPlainObject(value) &&
1741
+ isPlainObject(node[last])
1742
+ ? { ...node[last], ...value }
1743
+ : value;
1665
1744
  } else {
1666
1745
  acc[key] = updatedFormData[key];
1667
1746
  }
@@ -2115,10 +2194,12 @@ function Form({
2115
2194
  let value = data;
2116
2195
 
2117
2196
  for (let key of keys) {
2118
- value = value?.[key] || null;
2197
+ // ?? (not ||) so falsy-but-real values like
2198
+ // false, 0 and '' survive the walk
2199
+ value = value?.[key] ?? null;
2119
2200
 
2120
2201
  // If value becomes null, break out of the loop
2121
- if (!value) break;
2202
+ if (value === null) break;
2122
2203
  }
2123
2204
 
2124
2205
  updatedFormData[item] = value;
@@ -1,6 +1,7 @@
1
1
  import numeral from 'numeral';
2
2
  import moment from 'moment';
3
3
  import parse from 'html-react-parser';
4
+ import Select from 'react-select';
4
5
  import { Download, AlarmClock } from 'lucide-react';
5
6
  import { toast } from 'react-toastify';
6
7
  import CellWithTooltip from '../cells/CellWithTooltip';
@@ -361,16 +362,54 @@ export const renderDropdownColumn = ({
361
362
  filterEditor: filterEditor,
362
363
  filterEditorProps: filterEditorProps,
363
364
  render: ({ data }) => {
365
+ let optionsList =
366
+ dropdownData[column.id] && dropdownData[column.id].length > 0
367
+ ? dropdownData[column.id]
368
+ : column.options || [];
369
+
370
+ // Keep historical selections visible/selectable: if the row's
371
+ // current related entry isn't in the options (e.g. it has been
372
+ // deactivated), prepend it so the select can still display it.
373
+ const currentRelated = data[column.id];
374
+ if (
375
+ currentRelated &&
376
+ typeof currentRelated === 'object' &&
377
+ currentRelated.id &&
378
+ !optionsList.some((o) => o.id === currentRelated.id)
379
+ ) {
380
+ const currentLabel =
381
+ currentRelated.label ??
382
+ currentRelated.name ??
383
+ currentRelated.title;
384
+ if (currentLabel) {
385
+ optionsList = [
386
+ { id: currentRelated.id, label: currentLabel },
387
+ ...optionsList,
388
+ ];
389
+ }
390
+ }
391
+
364
392
  return (
365
393
  <select
366
394
  name={column.id}
367
395
  value={data[column.id]?.id || data[column.id] || ''}
368
- style={{
369
- padding: '7px',
396
+ style={{
397
+ padding: '7px 22px 7px 7px',
370
398
  width: '100%',
371
399
  border: 'none',
372
400
  backgroundColor: 'transparent',
373
401
  outline: 'none',
402
+ cursor: 'pointer',
403
+ // Consistent affordance across browsers: strip the
404
+ // native arrow and draw a subtle chevron so the
405
+ // cell visibly reads as a dropdown
406
+ appearance: 'none',
407
+ WebkitAppearance: 'none',
408
+ MozAppearance: 'none',
409
+ backgroundImage:
410
+ "url(\"data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%23999999' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>\")",
411
+ backgroundRepeat: 'no-repeat',
412
+ backgroundPosition: 'right 6px center',
374
413
  }}
375
414
  onChange={(e) => {
376
415
  e.preventDefault();
@@ -390,23 +429,23 @@ export const renderDropdownColumn = ({
390
429
  }}
391
430
  >
392
431
  <option value="">
393
- Select {/^[aeioAEIO]/.test(column.label) ? 'an' : 'a'}{' '}
394
- {column.label}
432
+ {/* fallbackKey: surface a legacy free-text value
433
+ (recorded before the relation existed) as the
434
+ placeholder so it stays readable until a managed
435
+ option is chosen */}
436
+ {!(data[column.id]?.id || data[column.id]) &&
437
+ column.fallbackKey &&
438
+ data[column.fallbackKey]
439
+ ? String(data[column.fallbackKey])
440
+ : `Select ${
441
+ /^[aeioAEIO]/.test(column.label) ? 'an' : 'a'
442
+ } ${column.label}`}
395
443
  </option>
396
- {dropdownData[column.id] &&
397
- dropdownData[column.id].length > 0
398
- ? dropdownData[column.id].map((option, index) => (
399
- <option value={option.id} key={index}>
400
- {option.label}
401
- </option>
402
- ))
403
- : column.options && column.options.length > 0
404
- ? column.options.map((option, index) => (
405
- <option value={option.id} key={index}>
406
- {option.label}
407
- </option>
408
- ))
409
- : null}
444
+ {optionsList.map((option, index) => (
445
+ <option value={option.id} key={index}>
446
+ {option.label}
447
+ </option>
448
+ ))}
410
449
  </select>
411
450
  );
412
451
  },
@@ -452,6 +491,120 @@ export const renderDropdownColumn = ({
452
491
  };
453
492
  };
454
493
 
494
+ // MultiDropdown Column Renderer — inline editor for belongsToMany
495
+ // relations. Chips come from the row's loaded relation (so historical
496
+ // selections of deactivated entries keep displaying); options come from
497
+ // the column's dropdown url; saving PUTs the selected id array to
498
+ // column.update.key, which DynamicController syncs on the pivot.
499
+ export const renderMultiDropdownColumn = ({
500
+ column,
501
+ commonProps,
502
+ filterEditor,
503
+ filterEditorProps,
504
+ dropdownData,
505
+ handleDropdownUpdate,
506
+ }) => {
507
+ const columnId = Array.isArray(column.id) ? column.id.join('-') : column.id;
508
+ const nameFrom = column.nameFrom || 'label';
509
+
510
+ return {
511
+ ...commonProps,
512
+ name: columnId,
513
+ filterEditor: filterEditor,
514
+ filterEditorProps: filterEditorProps,
515
+ render: ({ data }) => {
516
+ const relationKey = Array.isArray(column.id)
517
+ ? column.id[0]
518
+ : column.id;
519
+ const current = Array.isArray(data[relationKey])
520
+ ? data[relationKey]
521
+ : [];
522
+
523
+ const value = current.map((entry) => ({
524
+ value: entry.id,
525
+ label: entry[nameFrom] ?? entry.label ?? entry.name ?? '',
526
+ }));
527
+
528
+ const options = (
529
+ dropdownData[column.id]?.length
530
+ ? dropdownData[column.id]
531
+ : column.options || []
532
+ ).map((o) => ({ value: o.id, label: o.label }));
533
+
534
+ // fallbackKey: a legacy free-text value shows as the
535
+ // placeholder until managed entries are selected
536
+ const placeholder =
537
+ value.length === 0 &&
538
+ column.fallbackKey &&
539
+ data[column.fallbackKey]
540
+ ? String(data[column.fallbackKey])
541
+ : 'Select...';
542
+
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
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
+ );
600
+ }
601
+ }}
602
+ />
603
+ );
604
+ },
605
+ };
606
+ };
607
+
455
608
  // Image Column Renderer
456
609
  export const renderImageColumn = ({ column, commonProps }) => {
457
610
  return {
@@ -1524,6 +1677,17 @@ export const renderRelationArrayColumn = ({
1524
1677
  : '';
1525
1678
  }
1526
1679
 
1680
+ // fallbackKey: when the relation is empty, display a flat
1681
+ // field from the row instead (e.g. legacy free-text values
1682
+ // recorded before the relation existed)
1683
+ if (
1684
+ result === '' &&
1685
+ column.fallbackKey &&
1686
+ data[column.fallbackKey]
1687
+ ) {
1688
+ result = String(data[column.fallbackKey]);
1689
+ }
1690
+
1527
1691
  return (
1528
1692
  <CellWithTooltip
1529
1693
  value={result}
@@ -885,11 +885,35 @@ function GenericDetail({
885
885
  size,
886
886
  truncateLength,
887
887
  } = item;
888
- const value =
888
+ let value =
889
889
  relation && relation.length > 0 && type !== 'total'
890
890
  ? getValueFromData(data, [...relation], id, type)
891
891
  : getValueFromData(data, [], id, type);
892
892
 
893
+ // fallbackKey: when the resolved (relation) value is empty, fall
894
+ // back to a flat field on the record — keeps legacy free-text
895
+ // values visible after a field moves to a managed relation
896
+ if (
897
+ (value === '' || value === null || value === undefined) &&
898
+ item.fallbackKey &&
899
+ data[item.fallbackKey]
900
+ ) {
901
+ value = data[item.fallbackKey];
902
+ }
903
+ // suffixKey: append a companion flat field in parentheses (e.g.
904
+ // the "Other" free text alongside managed selections). Skipped
905
+ // when the value IS that field already (fallback case above).
906
+ else if (
907
+ value !== '' &&
908
+ value !== null &&
909
+ value !== undefined &&
910
+ item.suffixKey &&
911
+ data[item.suffixKey] &&
912
+ data[item.suffixKey] !== value
913
+ ) {
914
+ value = `${value} (${data[item.suffixKey]})`;
915
+ }
916
+
893
917
  const stringValue = String(value ?? ''); // Convert value to a string
894
918
 
895
919
  // Conditionally truncate the value if the 'truncate' key is present