@evoke-platform/ui-components 1.8.2-testing.0 → 1.9.0-testing.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/dist/published/components/custom/FormV2/FormRenderer.js +19 -16
- package/dist/published/components/custom/FormV2/FormRendererContainer.js +16 -4
- package/dist/published/components/custom/FormV2/components/AccordionSections.js +30 -29
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +1 -1
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +1 -2
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +16 -7
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +88 -39
- package/dist/published/components/custom/FormV2/components/FormSections.js +34 -3
- package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +10 -29
- package/dist/published/components/custom/FormV2/components/ValidationFiles/Validation.js +2 -2
- package/dist/published/components/custom/FormV2/components/types.d.ts +9 -1
- package/dist/published/components/custom/FormV2/components/utils.d.ts +18 -2
- package/dist/published/components/custom/FormV2/components/utils.js +163 -1
- package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +211 -2
- package/dist/published/components/custom/FormV2/tests/test-data.d.ts +9 -0
- package/dist/published/components/custom/FormV2/tests/test-data.js +134 -0
- package/dist/published/stories/FormRendererContainer.stories.d.ts +1 -0
- package/dist/published/stories/FormRendererContainer.stories.js +5 -0
- package/dist/published/stories/FormRendererData.d.ts +7 -0
- package/dist/published/stories/FormRendererData.js +172 -1
- package/dist/published/stories/sharedMswHandlers.js +5 -1
- 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 (
|
|
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
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 {
|
|
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 ??
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js
CHANGED
|
@@ -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)
|
|
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),
|
|
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
|
-
},
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
},
|
|
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: () =>
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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: (
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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: {
|