@visns-studio/visns-components 5.21.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.21.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": [
@@ -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,
@@ -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);
@@ -1448,6 +1502,8 @@ function Form({
1448
1502
  let fileUpload = false;
1449
1503
  let fileKey = '';
1450
1504
  let fileFieldSetting = '';
1505
+ const fileKeys = [];
1506
+ const fileFieldSettings = {};
1451
1507
 
1452
1508
  if (
1453
1509
  formSettings.customUrl &&
@@ -1518,11 +1574,14 @@ function Form({
1518
1574
  ) {
1519
1575
  fileUpload = true;
1520
1576
  fileKey = item;
1577
+ fileKeys.push(item);
1521
1578
 
1522
1579
  if (formSettings.fields.length > 0) {
1523
1580
  formSettings.fields.forEach((field) => {
1524
1581
  if (field.id === item) {
1525
1582
  fileFieldSetting = field;
1583
+ fileFieldSettings[item] =
1584
+ field;
1526
1585
  }
1527
1586
  });
1528
1587
  }
@@ -1608,60 +1667,134 @@ function Form({
1608
1667
  updatedFormData['uploadedFiles'] =
1609
1668
  await Promise.all(uploadPromises);
1610
1669
  } else {
1611
- // Optimize image if it's an image file
1612
- const optimizedFile =
1613
- await optimizeImageIfNeeded(
1614
- updatedFormData[fileKey]
1670
+ // Single-File fields. JSON forms can carry
1671
+ // several in one save (e.g. a bio's photo +
1672
+ // document); upload each and attach a
1673
+ // field-scoped descriptor so the backend
1674
+ // stores the file against that exact field.
1675
+ // Non-JSON forms keep the original
1676
+ // one-file-per-save behaviour, driven by the
1677
+ // request-level metadata below.
1678
+ const singleFileKeys = formSettings.json
1679
+ ? fileKeys.filter(
1680
+ (k) =>
1681
+ updatedFormData[k] instanceof
1682
+ File
1683
+ )
1684
+ : [fileKey];
1685
+
1686
+ for (const fieldId of singleFileKeys) {
1687
+ const originalName =
1688
+ updatedFormData[fieldId].name;
1689
+
1690
+ // Optimize image if it's an image file
1691
+ const optimizedFile =
1692
+ await optimizeImageIfNeeded(
1693
+ updatedFormData[fieldId]
1694
+ );
1695
+
1696
+ const toastId = toast.info(
1697
+ `Uploading ${originalName}`,
1698
+ {
1699
+ progress: 0,
1700
+ autoClose: false,
1701
+ }
1615
1702
  );
1616
1703
 
1617
- const toastId = toast.info(
1618
- `Uploading ${updatedFormData[fileKey].name}`,
1619
- {
1620
- progress: 0,
1621
- autoClose: false,
1622
- }
1623
- );
1704
+ const fileRes = await Vapor.store(
1705
+ optimizedFile,
1706
+ {
1707
+ progress: (progress) => {
1708
+ setUploadProgress(
1709
+ progress * 100
1710
+ );
1711
+ toast.update(toastId, {
1712
+ progress,
1713
+ });
1714
+ },
1715
+ }
1716
+ );
1624
1717
 
1625
- const fileRes = await Vapor.store(
1626
- optimizedFile,
1627
- {
1628
- progress: (progress) => {
1629
- setUploadProgress(progress * 100);
1630
- toast.update(toastId, {
1631
- progress,
1632
- });
1633
- },
1718
+ toast.update(toastId, {
1719
+ render: `${originalName} uploaded successfully!`,
1720
+ type: 'success',
1721
+ autoClose: 3000,
1722
+ });
1723
+
1724
+ updatedFormData['uuid'] = fileRes.uuid;
1725
+ updatedFormData['key'] = fileRes.key;
1726
+ updatedFormData['bucket'] = fileRes.bucket;
1727
+ updatedFormData['filename'] = originalName;
1728
+ updatedFormData['filesize'] =
1729
+ optimizedFile.size;
1730
+ updatedFormData['extension'] =
1731
+ fileRes.extension;
1732
+ updatedFormData['file_relationship'] =
1733
+ fieldId;
1734
+ updatedFormData['fileable_field'] = (
1735
+ fileFieldSettings[fieldId] ||
1736
+ fileFieldSetting
1737
+ ).fileable_field;
1738
+
1739
+ if (formSettings.json) {
1740
+ // Replace the raw File (which would
1741
+ // serialise to junk in the JSON
1742
+ // payload) with an upload descriptor
1743
+ // the backend materialises into a
1744
+ // {file_path, file_name, ...} object.
1745
+ updatedFormData[fieldId] = {
1746
+ _vaporUpload: true,
1747
+ uuid: fileRes.uuid,
1748
+ key: fileRes.key,
1749
+ bucket: fileRes.bucket,
1750
+ filename: originalName,
1751
+ filesize: optimizedFile.size,
1752
+ extension: fileRes.extension,
1753
+ };
1634
1754
  }
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;
1755
+ }
1655
1756
  }
1656
1757
  }
1657
1758
 
1658
- // Transform keys with dots into nested objects
1759
+ // Transform keys with dots into nested objects.
1760
+ // Walks every dot segment (not just two) so ids like
1761
+ // "transport.visit_before_after.yes_before_other" nest
1762
+ // correctly, and merges plain objects instead of
1763
+ // replacing them so sibling keys written by other
1764
+ // fields on the same path survive regardless of key
1765
+ // order.
1766
+ const isPlainObject = (value) =>
1767
+ value !== null &&
1768
+ typeof value === 'object' &&
1769
+ (value.constructor === Object ||
1770
+ value.constructor === undefined);
1771
+
1659
1772
  let payload = Object.keys(updatedFormData).reduce(
1660
1773
  (acc, key) => {
1661
1774
  if (key.includes('.')) {
1662
- const [root, sub] = key.split('.');
1663
- if (!acc[root]) acc[root] = {};
1664
- acc[root][sub] = updatedFormData[key];
1775
+ const segments = key.split('.');
1776
+ let node = acc;
1777
+
1778
+ for (
1779
+ let i = 0;
1780
+ i < segments.length - 1;
1781
+ i++
1782
+ ) {
1783
+ if (!isPlainObject(node[segments[i]])) {
1784
+ node[segments[i]] = {};
1785
+ }
1786
+ node = node[segments[i]];
1787
+ }
1788
+
1789
+ const last =
1790
+ segments[segments.length - 1];
1791
+ const value = updatedFormData[key];
1792
+
1793
+ node[last] =
1794
+ isPlainObject(value) &&
1795
+ isPlainObject(node[last])
1796
+ ? { ...node[last], ...value }
1797
+ : value;
1665
1798
  } else {
1666
1799
  acc[key] = updatedFormData[key];
1667
1800
  }
@@ -1737,6 +1870,7 @@ function Form({
1737
1870
  // In bulk edit mode, call saveAnother for each row in the group
1738
1871
  if (bulkEditMode && bulkEditGroupData?.groupRowIds?.length) {
1739
1872
  let hasError = false;
1873
+ const saveAnotherErrors = [];
1740
1874
  for (const rowId of bulkEditGroupData.groupRowIds) {
1741
1875
  const saveAnotherUrl = `${saveAnother.url}/${rowId}`;
1742
1876
  const resSaveAnother =
@@ -1747,12 +1881,27 @@ function Form({
1747
1881
  );
1748
1882
  if (resSaveAnother.data.error !== '') {
1749
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
+ }
1750
1895
  }
1751
1896
  }
1752
1897
  if (!hasError) {
1753
1898
  toast.success(saveAnother.message);
1754
1899
  } else {
1755
1900
  saveAnotherFailed = true;
1901
+ saveAnotherErrors.forEach(
1902
+ (error) =>
1903
+ toast.error(error)
1904
+ );
1756
1905
  }
1757
1906
  if (fetchTable) {
1758
1907
  const createdItem =
@@ -1779,6 +1928,9 @@ function Form({
1779
1928
  }
1780
1929
  } else {
1781
1930
  saveAnotherFailed = true;
1931
+ toast.error(
1932
+ resSaveAnother.data.error
1933
+ );
1782
1934
  if (fetchTable) {
1783
1935
  const createdItem =
1784
1936
  res.data.data || res.data;
@@ -2115,10 +2267,12 @@ function Form({
2115
2267
  let value = data;
2116
2268
 
2117
2269
  for (let key of keys) {
2118
- value = value?.[key] || null;
2270
+ // ?? (not ||) so falsy-but-real values like
2271
+ // false, 0 and '' survive the walk
2272
+ value = value?.[key] ?? null;
2119
2273
 
2120
2274
  // If value becomes null, break out of the loop
2121
- if (!value) break;
2275
+ if (value === null) break;
2122
2276
  }
2123
2277
 
2124
2278
  updatedFormData[item] = value;
@@ -1,6 +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';
5
+ import Select from 'react-select';
6
+ import CreatableSelect from 'react-select/creatable';
7
+ import CustomFetch from '../Fetch';
4
8
  import { Download, AlarmClock } from 'lucide-react';
5
9
  import { toast } from 'react-toastify';
6
10
  import CellWithTooltip from '../cells/CellWithTooltip';
@@ -345,6 +349,168 @@ export const renderColourColumn = ({ column, commonProps }) => {
345
349
  };
346
350
 
347
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
+
348
514
  export const renderDropdownColumn = ({
349
515
  column,
350
516
  commonProps,
@@ -361,16 +527,67 @@ export const renderDropdownColumn = ({
361
527
  filterEditor: filterEditor,
362
528
  filterEditorProps: filterEditorProps,
363
529
  render: ({ data }) => {
530
+ let optionsList =
531
+ dropdownData[column.id] && dropdownData[column.id].length > 0
532
+ ? dropdownData[column.id]
533
+ : column.options || [];
534
+
535
+ // Keep historical selections visible/selectable: if the row's
536
+ // current related entry isn't in the options (e.g. it has been
537
+ // deactivated), prepend it so the select can still display it.
538
+ const currentRelated = data[column.id];
539
+ if (
540
+ currentRelated &&
541
+ typeof currentRelated === 'object' &&
542
+ currentRelated.id &&
543
+ !optionsList.some((o) => o.id === currentRelated.id)
544
+ ) {
545
+ const currentLabel =
546
+ currentRelated.label ??
547
+ currentRelated.name ??
548
+ currentRelated.title;
549
+ if (currentLabel) {
550
+ optionsList = [
551
+ { id: currentRelated.id, label: currentLabel },
552
+ ...optionsList,
553
+ ];
554
+ }
555
+ }
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
+
364
570
  return (
365
571
  <select
366
572
  name={column.id}
367
573
  value={data[column.id]?.id || data[column.id] || ''}
368
- style={{
369
- padding: '7px',
574
+ style={{
575
+ padding: '7px 22px 7px 7px',
370
576
  width: '100%',
371
577
  border: 'none',
372
578
  backgroundColor: 'transparent',
373
579
  outline: 'none',
580
+ cursor: 'pointer',
581
+ // Consistent affordance across browsers: strip the
582
+ // native arrow and draw a subtle chevron so the
583
+ // cell visibly reads as a dropdown
584
+ appearance: 'none',
585
+ WebkitAppearance: 'none',
586
+ MozAppearance: 'none',
587
+ backgroundImage:
588
+ "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>\")",
589
+ backgroundRepeat: 'no-repeat',
590
+ backgroundPosition: 'right 6px center',
374
591
  }}
375
592
  onChange={(e) => {
376
593
  e.preventDefault();
@@ -390,23 +607,23 @@ export const renderDropdownColumn = ({
390
607
  }}
391
608
  >
392
609
  <option value="">
393
- Select {/^[aeioAEIO]/.test(column.label) ? 'an' : 'a'}{' '}
394
- {column.label}
610
+ {/* fallbackKey: surface a legacy free-text value
611
+ (recorded before the relation existed) as the
612
+ placeholder so it stays readable until a managed
613
+ option is chosen */}
614
+ {!(data[column.id]?.id || data[column.id]) &&
615
+ column.fallbackKey &&
616
+ data[column.fallbackKey]
617
+ ? String(data[column.fallbackKey])
618
+ : `Select ${
619
+ /^[aeioAEIO]/.test(column.label) ? 'an' : 'a'
620
+ } ${column.label}`}
395
621
  </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}
622
+ {optionsList.map((option, index) => (
623
+ <option value={option.id} key={index}>
624
+ {option.label}
625
+ </option>
626
+ ))}
410
627
  </select>
411
628
  );
412
629
  },
@@ -452,6 +669,111 @@ export const renderDropdownColumn = ({
452
669
  };
453
670
  };
454
671
 
672
+ // MultiDropdown Column Renderer — inline editor for belongsToMany
673
+ // relations. Chips come from the row's loaded relation (so historical
674
+ // selections of deactivated entries keep displaying); options come from
675
+ // the column's dropdown url; saving PUTs the selected id array to
676
+ // column.update.key, which DynamicController syncs on the pivot.
677
+ export const renderMultiDropdownColumn = ({
678
+ column,
679
+ commonProps,
680
+ filterEditor,
681
+ filterEditorProps,
682
+ dropdownData,
683
+ handleDropdownUpdate,
684
+ }) => {
685
+ const columnId = Array.isArray(column.id) ? column.id.join('-') : column.id;
686
+ const nameFrom = column.nameFrom || 'label';
687
+
688
+ return {
689
+ ...commonProps,
690
+ name: columnId,
691
+ filterEditor: filterEditor,
692
+ filterEditorProps: filterEditorProps,
693
+ render: ({ data }) => {
694
+ const relationKey = Array.isArray(column.id)
695
+ ? column.id[0]
696
+ : column.id;
697
+ const current = Array.isArray(data[relationKey])
698
+ ? data[relationKey]
699
+ : [];
700
+
701
+ const value = current.map((entry) => ({
702
+ value: entry.id,
703
+ label: entry[nameFrom] ?? entry.label ?? entry.name ?? '',
704
+ }));
705
+
706
+ const options = (
707
+ dropdownData[column.id]?.length
708
+ ? dropdownData[column.id]
709
+ : column.options || []
710
+ ).map((o) => ({ value: o.id, label: o.label }));
711
+
712
+ // fallbackKey: a legacy free-text value shows as the
713
+ // placeholder until managed entries are selected
714
+ const placeholder =
715
+ value.length === 0 &&
716
+ column.fallbackKey &&
717
+ data[column.fallbackKey]
718
+ ? String(data[column.fallbackKey])
719
+ : 'Select...';
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
+
734
+ return (
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
746
+ }
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>
772
+ );
773
+ },
774
+ };
775
+ };
776
+
455
777
  // Image Column Renderer
456
778
  export const renderImageColumn = ({ column, commonProps }) => {
457
779
  return {
@@ -1524,6 +1846,17 @@ export const renderRelationArrayColumn = ({
1524
1846
  : '';
1525
1847
  }
1526
1848
 
1849
+ // fallbackKey: when the relation is empty, display a flat
1850
+ // field from the row instead (e.g. legacy free-text values
1851
+ // recorded before the relation existed)
1852
+ if (
1853
+ result === '' &&
1854
+ column.fallbackKey &&
1855
+ data[column.fallbackKey]
1856
+ ) {
1857
+ result = String(data[column.fallbackKey]);
1858
+ }
1859
+
1527
1860
  return (
1528
1861
  <CellWithTooltip
1529
1862
  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