@evoke-platform/ui-components 1.8.2-dev.1 → 1.9.0-dev.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.
Files changed (22) hide show
  1. package/dist/published/components/custom/FormV2/FormRenderer.js +19 -16
  2. package/dist/published/components/custom/FormV2/FormRendererContainer.js +16 -4
  3. package/dist/published/components/custom/FormV2/components/AccordionSections.js +30 -29
  4. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +1 -1
  5. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +1 -2
  6. package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +16 -7
  7. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +88 -39
  8. package/dist/published/components/custom/FormV2/components/FormSections.js +34 -3
  9. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +10 -29
  10. package/dist/published/components/custom/FormV2/components/ValidationFiles/Validation.js +2 -2
  11. package/dist/published/components/custom/FormV2/components/types.d.ts +9 -1
  12. package/dist/published/components/custom/FormV2/components/utils.d.ts +18 -2
  13. package/dist/published/components/custom/FormV2/components/utils.js +163 -1
  14. package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +211 -2
  15. package/dist/published/components/custom/FormV2/tests/test-data.d.ts +9 -0
  16. package/dist/published/components/custom/FormV2/tests/test-data.js +134 -0
  17. package/dist/published/stories/FormRendererContainer.stories.d.ts +22 -0
  18. package/dist/published/stories/FormRendererContainer.stories.js +5 -0
  19. package/dist/published/stories/FormRendererData.d.ts +7 -0
  20. package/dist/published/stories/FormRendererData.js +172 -1
  21. package/dist/published/stories/sharedMswHandlers.js +5 -1
  22. package/package.json +2 -1
@@ -8,7 +8,7 @@ import { Box } from '../../layout';
8
8
  import ActionButtons from './components/ActionButtons';
9
9
  import { FormContext } from './components/FormContext';
10
10
  import { RecursiveEntryRenderer } from './components/RecursiveEntryRenderer';
11
- import { convertDocToParameters, convertPropertiesToParams } from './components/utils';
11
+ import { assignIdsToSectionsAndRichText, convertDocToParameters, convertPropertiesToParams } from './components/utils';
12
12
  import { handleValidation } from './components/ValidationFiles/Validation';
13
13
  import ValidationErrorDisplay from './components/ValidationFiles/ValidationErrorDisplay';
14
14
  function FormRenderer(props) {
@@ -40,6 +40,21 @@ function FormRenderer(props) {
40
40
  function handleCollapseAll() {
41
41
  setExpandAll(false);
42
42
  }
43
+ const parameters = useMemo(() => {
44
+ if (form.id === 'documentForm') {
45
+ return convertDocToParameters(instance);
46
+ }
47
+ else if (action?.parameters) {
48
+ return action.parameters;
49
+ }
50
+ else if (object) {
51
+ // if forms actionId is synced with object properties
52
+ return convertPropertiesToParams(object);
53
+ }
54
+ }, [form.id, action?.parameters, object, instance]);
55
+ const updatedEntries = useMemo(() => {
56
+ return assignIdsToSectionsAndRichText(entries, object, parameters);
57
+ }, [entries, object, parameters]);
43
58
  useEffect(() => {
44
59
  (async () => {
45
60
  try {
@@ -82,22 +97,10 @@ function FormRenderer(props) {
82
97
  }
83
98
  setTriggerFieldReset(true);
84
99
  };
85
- const parameters = useMemo(() => {
86
- if (form.id === 'documentForm') {
87
- return convertDocToParameters(instance);
88
- }
89
- else if (action?.parameters) {
90
- return action.parameters;
91
- }
92
- else if (object) {
93
- // if forms actionId is synced with object properties
94
- return convertPropertiesToParams(object);
95
- }
96
- }, [form.id, action?.parameters, object, instance]);
97
100
  useEffect(() => {
98
101
  handleValidation(entries, register, getValues(), action?.parameters, instance);
99
- }, []);
100
- if (entries && parameters && (!actionId || action)) {
102
+ }, [action?.parameters, instance, entries, register, getValues]);
103
+ if (parameters && (!actionId || action)) {
101
104
  return (React.createElement(React.Fragment, null,
102
105
  ((isSubmitted && !isEmpty(errors)) || (isSmallerThanMd && hasSections) || title) && (React.createElement(Box, { sx: {
103
106
  paddingX: isSmallerThanMd ? 2 : 3,
@@ -166,7 +169,7 @@ function FormRenderer(props) {
166
169
  // when rendering the default delete action, we don't want a border
167
170
  borderTop: !form.id || isModal ? undefined : '1px solid #e9ecef',
168
171
  } },
169
- entries.map((entry, index) => (React.createElement(RecursiveEntryRenderer, { key: index, entry: entry, isDocument: !!(form.id === 'documentForm') }))),
172
+ updatedEntries.map((entry, index) => (React.createElement(RecursiveEntryRenderer, { key: index, entry: entry, isDocument: !!(form.id === 'documentForm') }))),
170
173
  !hideButtons && (actionId || form.id === 'documentForm') && onSubmit && (React.createElement(Box, { sx: {
171
174
  ...(stickyFooter === false ? { position: 'static' } : { position: 'sticky' }),
172
175
  bottom: isModal || isSmallerThanMd ? 0 : 24,
@@ -6,7 +6,7 @@ import { Skeleton, Snackbar } from '../../core';
6
6
  import { Box } from '../../layout';
7
7
  import ErrorComponent from '../ErrorComponent';
8
8
  import { evalDefaultVals, processValueUpdate } from './components/DefaultValues';
9
- import { convertDocToEntries, deleteDocuments, encodePageSlug, formatDataToDoc, formatSubmission, getEntryId, getPrefixedUrl, getUnnestedEntries, isAddressProperty, isEmptyWithDefault, } from './components/utils';
9
+ import { convertDocToEntries, deleteDocuments, encodePageSlug, formatDataToDoc, formatSubmission, getEntryId, getPrefixedUrl, getUnnestedEntries, isAddressProperty, isEmptyWithDefault, plainTextToRtf, } from './components/utils';
10
10
  import FormRenderer from './FormRenderer';
11
11
  function FormRendererContainer(props) {
12
12
  const { instanceId, pageNavigation, documentId, dataType, display, formId, stickyFooter, objectId, actionId, richTextEditor, onClose, onSubmit, associatedObject, hideButtons, } = props;
@@ -225,7 +225,7 @@ function FormRendererContainer(props) {
225
225
  const saveHandler = async (submission) => {
226
226
  if (!form)
227
227
  return;
228
- submission = await formatSubmission(submission, apiServices, objectId, instanceId, setSnackbarError);
228
+ submission = await formatSubmission(submission, apiServices, objectId, instanceId, form, setSnackbarError);
229
229
  if (document) {
230
230
  submission = formatDataToDoc(submission);
231
231
  }
@@ -331,7 +331,10 @@ function FormRendererContainer(props) {
331
331
  const fieldValue = instanceData?.[fieldId] ??
332
332
  instanceData?.metadata?.[fieldId];
333
333
  const parameter = parameters?.find((param) => param.id === fieldId);
334
- if (associatedObject?.propertyId === fieldId && associatedObject?.instanceId && parameter) {
334
+ if (associatedObject?.propertyId === fieldId &&
335
+ associatedObject?.instanceId &&
336
+ parameter &&
337
+ action?.type === 'create') {
335
338
  try {
336
339
  const instance = await apiServices.get(getPrefixedUrl(`/objects/${parameter.objectId}/instances/${associatedObject.instanceId}`));
337
340
  result[associatedObject.propertyId] = instance;
@@ -360,7 +363,16 @@ function FormRendererContainer(props) {
360
363
  result[fieldId] = false;
361
364
  }
362
365
  else if (fieldValue !== undefined && fieldValue !== null) {
363
- result[fieldId] = fieldValue;
366
+ if (parameter?.type === 'richText' && typeof fieldValue === 'string') {
367
+ let RTFFieldValue = fieldValue;
368
+ if (!fieldValue.trim().startsWith('{\\rtf')) {
369
+ RTFFieldValue = plainTextToRtf(fieldValue);
370
+ }
371
+ result[fieldId] = RTFFieldValue;
372
+ }
373
+ else {
374
+ result[fieldId] = fieldValue;
375
+ }
364
376
  }
365
377
  }
366
378
  }
@@ -1,7 +1,6 @@
1
1
  import { ExpandMoreOutlined } from '@mui/icons-material';
2
- import { nanoid } from 'nanoid';
2
+ import { isEqual } from 'lodash';
3
3
  import React, { useEffect } from 'react';
4
- import { useResponsive } from '../../../../theme';
5
4
  import { useFormContext } from '../../../../theme/hooks';
6
5
  import { Accordion, AccordionDetails, AccordionSummary, Typography } from '../../../core';
7
6
  import { Box } from '../../../layout';
@@ -9,10 +8,8 @@ import { RecursiveEntryRenderer } from './RecursiveEntryRenderer';
9
8
  import { getErrorCountForSection } from './utils';
10
9
  function AccordionSections(props) {
11
10
  const { entry } = props;
12
- const { isMd, isLg, isXl } = useResponsive();
13
11
  const { errors, expandedSections, setExpandedSections, expandAll, setExpandAll, showSubmitError } = useFormContext();
14
12
  const lastSection = entry.sections.length - 1;
15
- const sectionsWithIds = React.useMemo(() => assignIds(entry.sections), [entry]);
16
13
  function collectNestedSections(entries = []) {
17
14
  const nestedSections = [];
18
15
  entries.forEach((entry) => {
@@ -29,36 +26,19 @@ function AccordionSections(props) {
29
26
  });
30
27
  return nestedSections;
31
28
  }
32
- // need to add ids to section so expanded sections can differentiate between sections with the same label
33
- function assignIds(sections) {
34
- const result = [];
35
- sections.forEach((section) => {
36
- const newSection = {
37
- ...section,
38
- id: nanoid(),
39
- };
40
- result.push(newSection);
41
- if (section.entries) {
42
- const nestedSections = collectNestedSections(section.entries);
43
- nestedSections.forEach((nestedSection) => {
44
- nestedSection.sections = assignIds(nestedSection.sections);
45
- });
46
- }
47
- });
48
- return result;
49
- }
50
29
  function getExpandedSections(sections, expandAll) {
51
30
  const expandedSections = [];
52
- const processSections = (sectionList) => {
31
+ const processSections = (sectionList, isNested) => {
53
32
  sectionList.forEach((section, index) => {
54
33
  expandedSections.push({
55
34
  label: section.label,
56
- expanded: expandAll ?? (index === 0 || isMd || isLg || isXl),
35
+ expanded: expandAll ?? index === 0,
57
36
  id: section?.id,
37
+ isNested: isNested,
58
38
  });
59
39
  if (section.entries) {
60
40
  const nestedSections = collectNestedSections(section.entries).flatMap((s) => s.sections);
61
- processSections(nestedSections);
41
+ processSections(nestedSections, true);
62
42
  }
63
43
  });
64
44
  };
@@ -66,17 +46,38 @@ function AccordionSections(props) {
66
46
  return expandedSections;
67
47
  }
68
48
  useEffect(() => {
69
- if (expandAll !== null) {
70
- setExpandedSections && setExpandedSections(getExpandedSections(sectionsWithIds, expandAll));
49
+ if (expandAll !== null && setExpandedSections) {
50
+ const newExpanded = getExpandedSections(entry.sections, expandAll);
51
+ if (!isEqual(newExpanded, expandedSections)) {
52
+ // Don't set state if the new expanded state is already reflected in the current state
53
+ const isAlreadySet = newExpanded.every((section) => expandedSections?.some((existing) => existing.id === section.id && existing.isNested === true)) ||
54
+ newExpanded.every((section) => expandedSections?.some((existing) => existing.id === section.id && existing.expanded === section.expanded));
55
+ if (!isAlreadySet) {
56
+ setExpandedSections((prev) => {
57
+ const mergedSections = [...(prev || [])];
58
+ newExpanded.forEach((newSection) => {
59
+ const existingIndex = mergedSections.findIndex((s) => s.id === newSection.id);
60
+ if (existingIndex >= 0) {
61
+ // Update existing section
62
+ mergedSections[existingIndex] = { ...mergedSections[existingIndex], ...newSection };
63
+ }
64
+ else {
65
+ mergedSections.push(newSection);
66
+ }
67
+ });
68
+ return mergedSections;
69
+ });
70
+ }
71
+ }
71
72
  }
72
- }, [expandAll, sectionsWithIds]);
73
+ }, [expandAll, entry, expandedSections]);
73
74
  const handleAccordionChange = (id) => {
74
75
  const updatedSections = expandedSections?.map((section) => section.id === id ? { ...section, expanded: !section.expanded } : section);
75
76
  if (setExpandedSections && updatedSections)
76
77
  setExpandedSections(updatedSections);
77
78
  setExpandAll && setExpandAll(null);
78
79
  };
79
- return (React.createElement(Box, null, sectionsWithIds.map((section, sectionIndex) => {
80
+ return (React.createElement(Box, null, entry.sections.map((section, sectionIndex) => {
80
81
  const errorCount = getErrorCountForSection(section, errors);
81
82
  return (React.createElement(Accordion, { key: section.id, expanded: expandedSections?.find((expandedSection) => expandedSection.id === section.id)?.expanded ??
82
83
  !!expandAll, onChange: () => handleAccordionChange(section.id), defaultExpanded: sectionIndex === 0, sx: {
@@ -344,7 +344,7 @@ const RepeatableField = (props) => {
344
344
  }, variant: "text", onClick: () => setReloadOnErrorTrigger((prevState) => !prevState) }, "Retry")));
345
345
  const save = async (action, input, instanceId) => {
346
346
  // when save is called we know that fieldDefinition is a parameter and fieldDefinition.objectId is defined
347
- input = await formatSubmission(input, apiServices, fieldDefinition.objectId, instanceId);
347
+ input = await formatSubmission(input, apiServices, fieldDefinition.objectId, instanceId, action.type === 'update' ? updateForm : undefined);
348
348
  if (action.type === 'create' && createActionId) {
349
349
  const updatedInput = {
350
350
  ...input,
@@ -109,8 +109,7 @@ export const Document = (props) => {
109
109
  } }, validate?.maxDocuments === 1
110
110
  ? `Maximum size is ${formattedMaxSize}.`
111
111
  : `The maximum size of each document is ${formattedMaxSize}.`)))))),
112
- canUpdateProperty && isNil(hasUpdatePermission) && (React.createElement(Skeleton, { variant: "rectangular", height: formattedMaxSize || allowedTypesMessage ? '136px' : '115px', sx: { margin: '5px 0', borderRadius: '8px' } })),
113
- React.createElement(DocumentList, { id: id, handleChange: handleChange, value: value, setSnackbarError: (type, message) => setSnackbarError({ message, type }), canUpdateProperty: canUpdateProperty && !!hasUpdatePermission }),
112
+ canUpdateProperty && isNil(hasUpdatePermission) ? (React.createElement(Skeleton, { variant: "rectangular", height: formattedMaxSize || allowedTypesMessage ? '136px' : '115px', sx: { margin: '5px 0', borderRadius: '8px' } })) : (React.createElement(DocumentList, { id: id, handleChange: handleChange, value: value, setSnackbarError: (type, message) => setSnackbarError({ message, type }), canUpdateProperty: canUpdateProperty && !!hasUpdatePermission })),
114
113
  React.createElement(Snackbar, { open: !!snackbarError?.message, handleClose: () => setSnackbarError(null), message: snackbarError?.message, error: snackbarError?.type === 'error' }),
115
114
  errors.length > 0 && (React.createElement(Box, { display: 'flex', alignItems: 'center' },
116
115
  React.createElement(InfoRounded, { sx: { fontSize: '.75rem', marginRight: '3px', color: '#D3271B' } }),
@@ -1,8 +1,8 @@
1
1
  import { useApiServices } from '@evoke-platform/context';
2
- import { ExpandMore } from '@mui/icons-material';
2
+ import { Close, ExpandMore } from '@mui/icons-material';
3
3
  import React, { useEffect, useState } from 'react';
4
4
  import { useFormContext } from '../../../../../theme/hooks';
5
- import { Autocomplete, Paper, TextField, Typography } from '../../../../core';
5
+ import { Autocomplete, IconButton, Paper, TextField, Typography } from '../../../../core';
6
6
  import { getPrefixedUrl, isOptionEqualToValue } from '../utils';
7
7
  const UserProperty = (props) => {
8
8
  const { id, error, value, readOnly, hasDescription } = props;
@@ -44,7 +44,14 @@ const UserProperty = (props) => {
44
44
  const updatedValue = typeof value?.value === 'string' ? { name: value.label, id: value.value } : null;
45
45
  handleChange(id, updatedValue);
46
46
  }
47
- return (options && (React.createElement(Autocomplete, { id: id, fullWidth: true, open: openOptions, popupIcon: userValue || readOnly ? '' : React.createElement(ExpandMore, null), PaperComponent: ({ children }) => {
47
+ return (options && (React.createElement(Autocomplete, { id: id, fullWidth: true, open: openOptions, popupIcon: userValue || readOnly ? '' : React.createElement(ExpandMore, null), clearIcon: !loadingOptions && userValue ? (React.createElement(IconButton, { size: "small", disableRipple: true, onKeyDown: (e) => {
48
+ if (e.key === 'Enter') {
49
+ e.stopPropagation();
50
+ }
51
+ }, onClick: () => setOpenOptions(false), "aria-label": "Clear selection", sx: {
52
+ padding: 0,
53
+ } },
54
+ React.createElement(Close, { sx: { fontSize: '20px' } }))) : null, PaperComponent: ({ children }) => {
48
55
  return (React.createElement(Paper, { sx: {
49
56
  borderRadius: '12px',
50
57
  boxShadow: '0px 24px 48px 0px rgba(145, 158, 171, 0.2)',
@@ -78,9 +85,7 @@ const UserProperty = (props) => {
78
85
  " ",
79
86
  '',
80
87
  users?.find((user) => option.value === user.id)?.status === 'Inactive' ? (React.createElement("span", null, "(Inactive)")) : (''))));
81
- }, onOpen: () => {
82
- setOpenOptions(true);
83
- }, onClose: () => setOpenOptions(false), value: userValue ?? '', options: options, getOptionLabel: (option) => {
88
+ }, onOpen: () => setOpenOptions(true), onClose: () => setOpenOptions(false), value: userValue ?? '', options: options, getOptionLabel: (option) => {
84
89
  if (typeof option === 'string') {
85
90
  return options.find((o) => o.value === option)?.label ?? '';
86
91
  }
@@ -94,11 +99,15 @@ const UserProperty = (props) => {
94
99
  }
95
100
  }
96
101
  }
97
- }, onKeyDownCapture: (e) => {
102
+ }, onKeyDown: (e) => {
98
103
  // prevents keyboard trap
99
104
  if (e.key === 'Tab') {
100
105
  return;
101
106
  }
107
+ if (e.key === 'Enter') {
108
+ setOpenOptions(true);
109
+ return;
110
+ }
102
111
  if (value) {
103
112
  e.preventDefault();
104
113
  }
@@ -2,10 +2,10 @@ import { useApiServices, useApp, useNavigate, } from '@evoke-platform/context';
2
2
  import cleanDeep from 'clean-deep';
3
3
  import { cloneDeep, debounce, isEmpty, isEqual, isNil } from 'lodash';
4
4
  import Handlebars from 'no-eval-handlebars';
5
- import React, { useCallback, useEffect, useMemo, useState } from 'react';
5
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
6
6
  import { Close } from '../../../../../../icons';
7
7
  import { useFormContext } from '../../../../../../theme/hooks';
8
- import { Autocomplete, Button, Dialog, DialogContent, DialogTitle, IconButton, Link, Paper, Snackbar, TextField, Tooltip, Typography, } from '../../../../../core';
8
+ import { Autocomplete, Button, Dialog, DialogContent, DialogTitle, IconButton, Link, ListItem, Paper, Snackbar, TextField, Tooltip, Typography, } from '../../../../../core';
9
9
  import { Box } from '../../../../../layout';
10
10
  import { encodePageSlug, getDefaultPages, getPrefixedUrl, transformToWhere } from '../../utils';
11
11
  import RelatedObjectInstance from './RelatedObjectInstance';
@@ -36,6 +36,7 @@ const ObjectPropertyInput = (props) => {
36
36
  const updatedCriteria = useMemo(() => {
37
37
  return filter ? { where: transformToWhere(filter) } : undefined;
38
38
  }, [filter]);
39
+ const listboxRef = useRef(null);
39
40
  useEffect(() => {
40
41
  if (relatedObject) {
41
42
  let defaultViewLayout;
@@ -242,9 +243,25 @@ const ObjectPropertyInput = (props) => {
242
243
  });
243
244
  }
244
245
  }, [relatedObject, options, hasFetched]);
246
+ const dropdownOptions = [
247
+ ...options.map((o) => ({ label: o.name, value: o.id })),
248
+ ...(mode !== 'existingOnly' && relatedObject?.actions?.some((a) => a.id === createActionId)
249
+ ? [
250
+ {
251
+ value: '__new__',
252
+ label: '+ Add New',
253
+ },
254
+ ]
255
+ : []),
256
+ ];
245
257
  return (React.createElement(React.Fragment, null,
246
258
  displayOption === 'dropdown' ? (React.createElement(React.Fragment, null,
247
- React.createElement(Autocomplete, { id: id, fullWidth: true, sortBy: "NONE", open: openOptions, size: fieldHeight, componentsProps: {
259
+ React.createElement(Autocomplete, { id: id, fullWidth: true, sortBy: "NONE", open: openOptions, clearIcon: React.createElement(IconButton, { size: "small", disableRipple: true, onKeyDown: (e) => {
260
+ if (e.key === 'Enter') {
261
+ e.stopPropagation();
262
+ }
263
+ }, onClick: () => setOpenOptions(false), "aria-label": "Clear selection", sx: { padding: 0 } },
264
+ React.createElement(Close, { sx: { fontSize: '20px' } })), size: fieldHeight, componentsProps: {
248
265
  popper: {
249
266
  modifiers: [
250
267
  {
@@ -272,19 +289,7 @@ const ObjectPropertyInput = (props) => {
272
289
  paddingLeft: '24px',
273
290
  color: 'rgba(145, 158, 171, 1)',
274
291
  },
275
- } },
276
- mode !== 'newOnly' && children,
277
- mode !== 'existingOnly' && createActionId && (React.createElement(Button, { fullWidth: true, sx: {
278
- justifyContent: 'flex-start',
279
- pl: 2,
280
- minHeight: '48px',
281
- borderTop: '1px solid rgba(145, 158, 171, 0.24)',
282
- borderRadius: '0p 0pc 6px 6px',
283
- paddingLeft: '22px',
284
- fontWeight: 400,
285
- }, onMouseDown: (e) => {
286
- setOpenCreateDialog(true);
287
- }, color: 'inherit' }, "+ Add New"))));
292
+ } }, mode !== 'newOnly' && children));
288
293
  }, sx: {
289
294
  '& button.MuiButtonBase-root': {
290
295
  ...(!loadingOptions &&
@@ -296,18 +301,27 @@ const ObjectPropertyInput = (props) => {
296
301
  },
297
302
  backgroundColor: 'white', // prevents the field flickering gray when loading in the value and options when it's a read only
298
303
  }, noOptionsText: 'No options available', renderOption: (props, option) => {
299
- return (React.createElement("li", { ...props, key: option.id },
304
+ const isAddNew = option.value === '__new__';
305
+ return (React.createElement(ListItem, { ...props, key: props.id, sx: {
306
+ ...(isAddNew
307
+ ? {
308
+ borderTop: '1px solid rgba(145, 158, 171, 0.24)',
309
+ position: 'sticky',
310
+ bottom: 0,
311
+ backgroundColor: 'white',
312
+ '&:hover': {
313
+ backgroundColor: '#f5f5f5 !important',
314
+ },
315
+ '&.Mui-focused': {
316
+ backgroundColor: '#f5f5f5 !important',
317
+ },
318
+ }
319
+ : {}),
320
+ } },
300
321
  React.createElement(Box, null,
301
322
  React.createElement(Typography, { sx: { marginLeft: '8px', fontSize: '14px' } }, option.label),
302
- layout?.secondaryTextExpression ? (React.createElement(Typography, { sx: { marginLeft: '8px', fontSize: '14px', color: '#637381' } }, compileExpression(layout?.secondaryTextExpression, options.find((o) => o.id === option.value)))) : null)));
303
- }, onOpen: () => {
304
- if (instance?.[id]?.id || selectedInstance?.id) {
305
- setOpenOptions(false);
306
- }
307
- else {
308
- setOpenOptions(true);
309
- }
310
- }, onClose: () => setOpenOptions(false), value: selectedInstance?.id
323
+ layout?.secondaryTextExpression && !isAddNew ? (React.createElement(Typography, { sx: { marginLeft: '8px', fontSize: '14px', color: '#637381' } }, compileExpression(layout?.secondaryTextExpression, options.find((o) => o.id === option.value)))) : null)));
324
+ }, onOpen: () => setOpenOptions(true), onClose: () => setOpenOptions(false), value: selectedInstance?.id
311
325
  ? {
312
326
  value: selectedInstance?.id ?? '',
313
327
  label: selectedInstance?.name ?? '',
@@ -317,15 +331,52 @@ const ObjectPropertyInput = (props) => {
317
331
  return option.value === value;
318
332
  }
319
333
  return option.value === value?.value;
320
- }, options: options.map((o) => ({ label: o.name, value: o.id })), getOptionLabel: (option) => {
334
+ }, options: dropdownOptions, filterOptions: (options) => options, getOptionLabel: (option) => {
321
335
  return typeof option === 'string'
322
336
  ? (options.find((o) => o.id === option)?.name ?? '')
323
337
  : option.label;
324
- }, onKeyDownCapture: (e) => {
338
+ }, ListboxProps: {
339
+ ref: listboxRef,
340
+ sx: {
341
+ padding: 0,
342
+ },
343
+ }, onHighlightChange: (e, option) => {
344
+ if (!option || !e || !('key' in e) || !listboxRef.current)
345
+ return;
346
+ const highlightedIndex = dropdownOptions.findIndex((opt) => opt.value === option.value);
347
+ // Only handle regular options (not the "Add New" button)
348
+ if (highlightedIndex >= options.length)
349
+ return;
350
+ const listbox = listboxRef.current;
351
+ const items = listbox.querySelectorAll('li');
352
+ const highlightedElement = items[highlightedIndex];
353
+ const addNewButton = mode !== 'existingOnly' && relatedObject?.actions?.some((a) => a.id === createActionId)
354
+ ? items[items.length - 1]
355
+ : undefined;
356
+ if (!highlightedElement || !addNewButton)
357
+ return;
358
+ const buttonHeight = addNewButton.offsetHeight;
359
+ const elementTop = highlightedElement.offsetTop;
360
+ const elementHeight = highlightedElement.offsetHeight;
361
+ const listboxScrollTop = listbox.scrollTop;
362
+ const listboxHeight = listbox.clientHeight;
363
+ // Check if element is hidden below the sticky button
364
+ const elementBottom = elementTop + elementHeight;
365
+ const visibleBottom = listboxScrollTop + listboxHeight - buttonHeight;
366
+ if (elementBottom > visibleBottom) {
367
+ // Scroll just enough to show the element above the sticky button
368
+ listbox.scrollTop = elementBottom - listboxHeight + buttonHeight;
369
+ }
370
+ }, onKeyDown: (e) => {
325
371
  // prevents keyboard trap
326
372
  if (e.key === 'Tab') {
327
373
  return;
328
374
  }
375
+ if (e.key === 'Enter') {
376
+ setOpenOptions(true);
377
+ setDropdownInput(undefined);
378
+ return;
379
+ }
329
380
  if (selectedInstance?.id) {
330
381
  e.preventDefault();
331
382
  }
@@ -335,21 +386,19 @@ const ObjectPropertyInput = (props) => {
335
386
  setSelectedInstance(undefined);
336
387
  handleChangeObjectField(id, null);
337
388
  }
389
+ else if (value?.value === '__new__') {
390
+ setOpenCreateDialog(true);
391
+ }
338
392
  else {
339
393
  const selectedInstance = options.find((o) => o.id === value?.value);
340
394
  setSelectedInstance(selectedInstance);
341
395
  handleChangeObjectField(id, selectedInstance);
342
396
  }
343
- }, selectOnFocus: false, onBlur: () => setDropdownInput(undefined), renderInput: (params) => (React.createElement(TextField, { ...params, placeholder: selectedInstance?.id || readOnly ? '' : 'Select', readOnly: !loadingOptions && !selectedInstance?.id && readOnly, onChange: (event) => setDropdownInput(event.target.value), onClick: (e) => {
344
- if (openOptions &&
345
- e.target?.nodeName === 'svg') {
346
- setOpenOptions(false);
347
- }
348
- else {
349
- setOptions(options);
350
- setOpenOptions(true);
351
- }
352
- }, sx: {
397
+ }, selectOnFocus: false, onBlur: () => {
398
+ if (dropdownInput) {
399
+ getDropdownOptions();
400
+ }
401
+ }, renderInput: (params) => (React.createElement(TextField, { ...params, placeholder: selectedInstance?.id || readOnly ? '' : 'Select', readOnly: !loadingOptions && !selectedInstance?.id && readOnly, onChange: (event) => setDropdownInput(event.target.value), sx: {
353
402
  ...(!loadingOptions && selectedInstance?.id
354
403
  ? {
355
404
  '.MuiOutlinedInput-root': {
@@ -380,7 +429,7 @@ const ObjectPropertyInput = (props) => {
380
429
  : {}),
381
430
  }, InputProps: {
382
431
  ...params.InputProps,
383
- startAdornment: selectedInstance?.id ? (React.createElement(Typography, { onClick: (e) => {
432
+ startAdornment: selectedInstance?.id ? (React.createElement(Typography, { onClick: () => {
384
433
  if (navigationSlug && selectedInstance?.id) {
385
434
  navigateTo(`/${appId}/${navigationSlug.replace(':instanceId', selectedInstance.id)}`);
386
435
  }
@@ -1,6 +1,6 @@
1
1
  import RadioButtonCheckedIcon from '@mui/icons-material/RadioButtonChecked';
2
2
  import { TabContext, TabPanel } from '@mui/lab';
3
- import React, { useRef, useState } from 'react';
3
+ import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
4
  import { useFormContext } from '../../../../theme/hooks';
5
5
  import { Tab, Tabs, Typography } from '../../../core';
6
6
  import { Box } from '../../../layout';
@@ -9,11 +9,42 @@ import TabNav from './TabNav';
9
9
  import { getErrorCountForSection, scrollIntoViewWithOffset } from './utils';
10
10
  function FormSections(props) {
11
11
  const { entry } = props;
12
- const { errors, showSubmitError } = useFormContext();
12
+ const { errors, showSubmitError, setFetchedOptions, fetchedOptions } = useFormContext();
13
13
  const tabPanelsRef = useRef(null);
14
14
  const [tabValue, setTabValue] = useState(0);
15
+ const currentSectionRef = useRef(fetchedOptions[`section-${entry.id}`] || null);
16
+ useEffect(() => {
17
+ // Preserve nested section state when unmounted so navigation doesn't reset it
18
+ setFetchedOptions({
19
+ [`section-${entry.id}`]: currentSectionRef.current,
20
+ });
21
+ }, [currentSectionRef.current]);
22
+ // useLayoutEffect ensures the tab stays aligned with the current section
23
+ // and prevents a visible flicker when sections are added or removed
24
+ useLayoutEffect(() => {
25
+ if (!currentSectionRef.current && entry.sections[tabValue]) {
26
+ currentSectionRef.current = entry.sections[tabValue];
27
+ return;
28
+ }
29
+ const currentIndex = entry.sections.findIndex((section) => section === currentSectionRef.current || section.id === currentSectionRef.current?.id);
30
+ // Current section no longer exists, find the closest valid tab
31
+ if (currentIndex === -1) {
32
+ const newTabIndex = Math.min(tabValue, entry.sections.length - 1);
33
+ setTabValue(newTabIndex);
34
+ currentSectionRef.current = entry.sections[newTabIndex];
35
+ }
36
+ else {
37
+ // Found the same section at a different index, update tabValue
38
+ if (currentIndex !== tabValue) {
39
+ setTabValue(currentIndex);
40
+ }
41
+ currentSectionRef.current = entry.sections[currentIndex];
42
+ }
43
+ }, [entry.sections]);
15
44
  const handleTabChange = (type, newValue) => {
16
- setTabValue(Number(newValue));
45
+ const newIndex = Number(newValue);
46
+ setTabValue(newIndex);
47
+ currentSectionRef.current = entry.sections[newIndex];
17
48
  if (tabPanelsRef.current && type === 'buttonNav') {
18
49
  scrollIntoViewWithOffset(tabPanelsRef.current, 170);
19
50
  }
@@ -1,5 +1,6 @@
1
1
  import { useApiServices, useAuthenticationContext, } from '@evoke-platform/context';
2
2
  import { WarningRounded } from '@mui/icons-material';
3
+ import DOMPurify from 'dompurify';
3
4
  import React, { useEffect, useMemo } from 'react';
4
5
  import { useResponsive } from '../../../../theme';
5
6
  import { useFormContext } from '../../../../theme/hooks';
@@ -17,7 +18,7 @@ import { Image } from './FormFieldTypes/Image';
17
18
  import ObjectPropertyInput from './FormFieldTypes/relatedObjectFiles/ObjectPropertyInput';
18
19
  import UserProperty from './FormFieldTypes/UserProperty';
19
20
  import FormSections from './FormSections';
20
- import { docProperties, entryIsVisible, fetchCollectionData, getEntryId, isAddressProperty, isOptionEqualToValue, updateCriteriaInputs, } from './utils';
21
+ import { entryIsVisible, fetchCollectionData, filterEmptySections, getEntryId, getFieldDefinition, isAddressProperty, isOptionEqualToValue, updateCriteriaInputs, } from './utils';
21
22
  function getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors, validation) {
22
23
  return {
23
24
  inputId: entryId,
@@ -42,39 +43,17 @@ export function RecursiveEntryRenderer(props) {
42
43
  if (!entryIsVisible(entry, getValues(), instance)) {
43
44
  return null;
44
45
  }
46
+ const { isXs, smallerThan } = useResponsive();
47
+ const isSmallerThanMd = smallerThan('md');
45
48
  const apiServices = useApiServices();
46
49
  const userAccount = useAuthenticationContext()?.account;
47
- const { smallerThan, isXs } = useResponsive();
48
50
  const entryId = getEntryId(entry) || 'defaultId';
49
51
  const display = 'display' in entry ? entry.display : undefined;
50
52
  const fieldValue = entry.type === 'readonlyField' ? instance?.[entryId] : getValues(entryId);
51
53
  const initialMiddleObjectInstances = fetchedOptions[`${entryId}InitialMiddleObjectInstances`];
52
54
  const middleObject = fetchedOptions[`${entryId}MiddleObject`];
53
55
  const fieldDefinition = useMemo(() => {
54
- let def;
55
- if (entry.type === 'input') {
56
- def = parameters?.find((param) => param.id === entry.parameterId);
57
- }
58
- else if (entry.type === 'readonlyField') {
59
- def = isDocument
60
- ? docProperties.find((prop) => prop.id === entry.propertyId)
61
- : isAddressProperty(entry.propertyId)
62
- ? object?.properties?.find((prop) => prop.id === entry.propertyId.split('.')[0])
63
- : object?.properties?.find((prop) => prop.id === entry.propertyId);
64
- }
65
- else if (entry.type === 'inputField') {
66
- def = entry.input;
67
- }
68
- else {
69
- return undefined;
70
- }
71
- if (def?.enum && def.type === 'string') {
72
- const cloned = structuredClone(def);
73
- // single select must be made to be type choices for label and error handling
74
- cloned.type = 'choices';
75
- return cloned;
76
- }
77
- return def;
56
+ return getFieldDefinition(entry, object, parameters, isDocument);
78
57
  }, [entry, parameters, object]);
79
58
  const validation = fieldDefinition?.validation || {};
80
59
  if (associatedObject?.propertyId === entryId)
@@ -85,7 +64,7 @@ export function RecursiveEntryRenderer(props) {
85
64
  }
86
65
  }, [fieldDefinition, instance]);
87
66
  if (entry.type === 'content') {
88
- return (React.createElement(Box, { dangerouslySetInnerHTML: { __html: entry.html }, sx: {
67
+ return (React.createElement(Box, { dangerouslySetInnerHTML: { __html: DOMPurify.sanitize(entry.html) }, sx: {
89
68
  fontFamily: 'Roboto, Helvetica, Arial, sans-serif',
90
69
  } }));
91
70
  }
@@ -123,7 +102,8 @@ export function RecursiveEntryRenderer(props) {
123
102
  }
124
103
  else if (fieldDefinition.type === 'richText') {
125
104
  return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) }, richTextEditor ? (React.createElement(richTextEditor, {
126
- id: entryId,
105
+ // RichTexts get a uniqueId when the form is loaded to prevent issues with multiple rich text fields on one form
106
+ id: entry.uniqueId,
127
107
  value: fieldValue,
128
108
  handleUpdate: (value) => handleChange(entryId, value),
129
109
  format: 'rtf',
@@ -195,7 +175,8 @@ export function RecursiveEntryRenderer(props) {
195
175
  }))))));
196
176
  }
197
177
  else if (entry.type === 'sections') {
198
- return smallerThan('md') ? React.createElement(AccordionSections, { entry: entry }) : React.createElement(FormSections, { entry: entry });
178
+ const filteredEntry = filterEmptySections(entry, getValues(), instance);
179
+ return filteredEntry ? (isSmallerThanMd ? (React.createElement(AccordionSections, { entry: filteredEntry })) : (React.createElement(FormSections, { entry: filteredEntry }))) : null;
199
180
  }
200
181
  else if (!fieldDefinition) {
201
182
  return (React.createElement(Box, { sx: {