@evoke-platform/ui-components 1.10.0-dev.1 → 1.10.0-dev.10

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 (38) hide show
  1. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.js +1 -1
  2. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.test.d.ts +1 -0
  3. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.test.js +430 -0
  4. package/dist/published/components/custom/CriteriaBuilder/ValueEditor.js +19 -6
  5. package/dist/published/components/custom/Form/utils.js +1 -0
  6. package/dist/published/components/custom/FormV2/FormRenderer.d.ts +2 -1
  7. package/dist/published/components/custom/FormV2/FormRenderer.js +15 -3
  8. package/dist/published/components/custom/FormV2/FormRendererContainer.d.ts +0 -2
  9. package/dist/published/components/custom/FormV2/FormRendererContainer.js +92 -18
  10. package/dist/published/components/custom/FormV2/components/Footer.d.ts +1 -0
  11. package/dist/published/components/custom/FormV2/components/Footer.js +4 -3
  12. package/dist/published/components/custom/FormV2/components/FormContext.d.ts +2 -1
  13. package/dist/published/components/custom/FormV2/components/FormFieldTypes/AddressFields.js +30 -13
  14. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/ActionDialog.js +1 -1
  15. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.d.ts +0 -3
  16. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +31 -27
  17. package/dist/published/components/custom/FormV2/components/FormFieldTypes/Criteria.js +16 -3
  18. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +16 -4
  19. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.d.ts +1 -0
  20. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.js +16 -3
  21. package/dist/published/components/custom/FormV2/components/FormFieldTypes/Image.js +32 -7
  22. package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +15 -3
  23. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +70 -18
  24. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/RelatedObjectInstance.js +37 -15
  25. package/dist/published/components/custom/FormV2/components/Header.d.ts +2 -0
  26. package/dist/published/components/custom/FormV2/components/Header.js +47 -6
  27. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +33 -19
  28. package/dist/published/components/custom/FormV2/components/utils.js +4 -7
  29. package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +432 -4
  30. package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +651 -13
  31. package/dist/published/components/custom/FormV2/tests/test-data.d.ts +1 -0
  32. package/dist/published/components/custom/FormV2/tests/test-data.js +140 -0
  33. package/dist/published/stories/FormRenderer.stories.d.ts +8 -4
  34. package/dist/published/stories/FormRendererContainer.stories.d.ts +0 -10
  35. package/dist/published/stories/FormRendererContainer.stories.js +7 -3
  36. package/dist/published/stories/FormRendererData.js +3 -43
  37. package/dist/published/theme/hooks.d.ts +4 -3
  38. package/package.json +3 -1
@@ -1,11 +1,12 @@
1
1
  import { useApiServices, useApp, useAuthenticationContext, useNavigate, useObject, } from '@evoke-platform/context';
2
2
  import axios from 'axios';
3
- import { get, isArray, isEmpty, isEqual, merge, omit, pick, set, uniq } from 'lodash';
4
- import React, { useEffect, useState } from 'react';
3
+ import { cloneDeep, get, isArray, isEmpty, isEqual, merge, omit, pick, set, uniq } from 'lodash';
4
+ import React, { useEffect, useRef, useState } from 'react';
5
5
  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 Header from './components/Header';
9
10
  import { convertDocToEntries, deleteDocuments, encodePageSlug, formatDataToDoc, formatSubmission, getEntryId, getPrefixedUrl, getUnnestedEntries, isAddressProperty, isEmptyWithDefault, plainTextToRtf, } from './components/utils';
10
11
  import FormRenderer from './FormRenderer';
11
12
  function FormRendererContainer(props) {
@@ -20,14 +21,34 @@ function FormRendererContainer(props) {
20
21
  const [parameters, setParameters] = useState();
21
22
  const [document, setDocument] = useState();
22
23
  const [instance, setInstance] = useState();
23
- const [formData, setFormData] = useState();
24
+ const formDataRef = useRef();
25
+ // We only need the setter to force a re-render when form data updates; the value itself
26
+ // is intentionally not referenced elsewhere to avoid stale reads (we use formDataRef).
27
+ // Keep the setter to allow updating a version counter without declaring the value
28
+ // which would trigger a lint error for being unused.
29
+ const [, setFormDataVersion] = useState(0);
24
30
  const [action, setAction] = useState();
31
+ /**
32
+ * Updates form data synchronously and triggers a re-render.
33
+ *
34
+ * This function uses a ref for synchronous updates (to avoid race conditions in autosave)
35
+ * combined with a version counter to trigger React re-renders. This ensures that:
36
+ * 1. formDataRef.current is updated immediately (synchronous)
37
+ * 2. Components that depend on formData will re-render (via version increment)
38
+ * 3. Autosave always reads the latest data without timing issues
39
+ */
40
+ const setFormData = (newData) => {
41
+ formDataRef.current = newData;
42
+ setFormDataVersion((v) => v + 1);
43
+ };
25
44
  const [error, setError] = useState();
26
45
  const [form, setForm] = useState();
27
46
  const [snackbarError, setSnackbarError] = useState({
28
47
  showAlert: false,
29
48
  isError: true,
30
49
  });
50
+ const [isSaving, setIsSaving] = useState(false);
51
+ const [lastSavedData, setLastSavedData] = useState({});
31
52
  const userAccount = useAuthenticationContext()?.account;
32
53
  const objectStore = useObject(form?.objectId ?? objectId);
33
54
  const onError = (err) => {
@@ -59,6 +80,10 @@ function FormRendererContainer(props) {
59
80
  const action = object?.actions?.find((a) => a.id === (form?.actionId || actionId));
60
81
  if (action && (instanceId || action.type === 'create')) {
61
82
  setAction(action);
83
+ // Clear error if action is found after being missing
84
+ // TODO: This entire effect should take place after form is fetched to avoid an error flickering
85
+ // That is, this effect should be merged with the one below that fetches the form
86
+ setError((prevError) => prevError === 'Action could not be found' ? undefined : prevError);
62
87
  }
63
88
  else {
64
89
  setError('Action could not be found');
@@ -99,7 +124,8 @@ function FormRendererContainer(props) {
99
124
  apiServices
100
125
  .get(getPrefixedUrl(`/forms/${formId || action?.defaultFormId}`))
101
126
  .then((evokeForm) => {
102
- if (evokeForm?.actionId === actionId) {
127
+ // If an actionId is provided, ensure it matches the form's actionId
128
+ if (!actionId || evokeForm?.actionId === actionId) {
103
129
  const form = evokeForm;
104
130
  setForm(form);
105
131
  }
@@ -184,6 +210,8 @@ function FormRendererContainer(props) {
184
210
  if (document && objectId) {
185
211
  const defaultValues = await getDefaultValues(convertDocToEntries(document), document);
186
212
  setFormData(defaultValues);
213
+ // Deep clone to avoid reference issues
214
+ setLastSavedData(cloneDeep(defaultValues));
187
215
  if (!form) {
188
216
  setForm({
189
217
  id: 'documentForm',
@@ -196,6 +224,8 @@ function FormRendererContainer(props) {
196
224
  else if (form && (instance || !instanceId)) {
197
225
  const defaultValues = await getDefaultValues(form.entries, instance || {});
198
226
  setFormData(defaultValues);
227
+ // Deep clone to avoid reference issues
228
+ setLastSavedData(cloneDeep(defaultValues));
199
229
  }
200
230
  };
201
231
  getInitialValues();
@@ -245,8 +275,8 @@ function FormRendererContainer(props) {
245
275
  else if (action?.type === 'create') {
246
276
  const response = await apiServices.post(getPrefixedUrl(`/objects/${form.objectId}/instances/actions`), {
247
277
  actionId: form.actionId,
248
- input: pick(submission, sanitizedObject?.properties
249
- ?.filter((property) => !property.formula && property.type !== 'collection')
278
+ input: omit(submission, sanitizedObject?.properties
279
+ ?.filter((property) => property.formula || property.type === 'collection')
250
280
  .map((property) => property.id) ?? []),
251
281
  });
252
282
  if (response) {
@@ -256,8 +286,8 @@ function FormRendererContainer(props) {
256
286
  else if (instanceId && action) {
257
287
  const response = await objectStore.instanceAction(instanceId, {
258
288
  actionId: action.id,
259
- input: pick(submission, sanitizedObject?.properties
260
- ?.filter((property) => !property.formula && property.type !== 'collection')
289
+ input: omit(submission, sanitizedObject?.properties
290
+ ?.filter((property) => property.formula || property.type === 'collection')
261
291
  .map((property) => property.id) ?? []),
262
292
  });
263
293
  if (sanitizedObject && instance) {
@@ -346,7 +376,7 @@ function FormRendererContainer(props) {
346
376
  for (const { fieldId, fieldValue } of defaultValuesArray) {
347
377
  const parameter = parameters?.find((param) => param.id === fieldId);
348
378
  if (parameter?.type === 'object') {
349
- const dependentFields = await processValueUpdate(form?.entries, parameters, fieldValue, apiServices, fieldId, formData, userAccount);
379
+ const dependentFields = await processValueUpdate(form?.entries, parameters, fieldValue, apiServices, fieldId, formDataRef.current, userAccount);
350
380
  for (const field of dependentFields) {
351
381
  set(result, field.fieldId, field.fieldValue);
352
382
  }
@@ -377,6 +407,45 @@ function FormRendererContainer(props) {
377
407
  await processEntries(entries);
378
408
  return result;
379
409
  };
410
+ const handleAutosave = async (fieldId) => {
411
+ if (!form?.autosaveActionId || !formDataRef.current) {
412
+ return;
413
+ }
414
+ const currentValue = get(formDataRef.current, fieldId);
415
+ const lastValue = get(lastSavedData, fieldId);
416
+ if (isEqual(currentValue, lastValue)) {
417
+ return; // Field hasn't changed, skip save
418
+ }
419
+ try {
420
+ setIsSaving(true);
421
+ const submission = await formatSubmission(formDataRef.current, apiServices, objectId, instanceId, form, setSnackbarError);
422
+ // Handle document autosave
423
+ if (dataType === 'documents' && document) {
424
+ await apiServices.patch(getPrefixedUrl(`/objects/${objectId}/instances/${instanceId}/documents/${documentId}`), pick(submission, ['metadata']).metadata ?? submission);
425
+ setDocument((prev) => ({
426
+ ...prev,
427
+ metadata: submission.metadata,
428
+ }));
429
+ }
430
+ // Handle object instance autosave
431
+ else if (instanceId && action?.type === 'update') {
432
+ await apiServices.post(getPrefixedUrl(`/objects/${objectId}/instances/${instanceId}/actions`), {
433
+ actionId: form.autosaveActionId,
434
+ input: pick(submission, sanitizedObject?.properties
435
+ ?.filter((property) => !property.formula && property.type !== 'collection')
436
+ .map((property) => property.id) ?? []),
437
+ });
438
+ }
439
+ setLastSavedData(cloneDeep(formDataRef.current));
440
+ setIsSaving(false);
441
+ }
442
+ catch (error) {
443
+ console.error('Autosave failed:', error);
444
+ setIsSaving(false);
445
+ }
446
+ };
447
+ // Autosave is enabled if form.autosaveActionId exists.
448
+ const onAutosave = form?.autosaveActionId ? handleAutosave : undefined;
380
449
  async function onChange(id, value) {
381
450
  const parameter = parameters?.find((param) => param.id === id);
382
451
  const entries = getUnnestedEntries(form.entries);
@@ -387,7 +456,7 @@ function FormRendererContainer(props) {
387
456
  if (parameter) {
388
457
  if (parameter.type === 'object' && parameters && parameters.length > 0) {
389
458
  // On change of a related object, update default values dependent on that object
390
- const dependentFields = await processValueUpdate(form?.entries, parameters, value, apiServices, id, formData, userAccount);
459
+ const dependentFields = await processValueUpdate(form?.entries, parameters, value, apiServices, id, formDataRef.current, userAccount);
391
460
  for (const field of dependentFields) {
392
461
  onChange(field.fieldId, field.fieldValue);
393
462
  }
@@ -398,16 +467,21 @@ function FormRendererContainer(props) {
398
467
  value = value.value ? value.value : value;
399
468
  }
400
469
  }
401
- if (!isEqual(value, get(formData, id))) {
402
- setFormData((prev) => {
403
- const newData = { ...prev };
404
- set(newData, id, value);
405
- return newData;
406
- });
470
+ if (!isEqual(value, get(formDataRef.current, id))) {
471
+ const newData = { ...formDataRef.current };
472
+ set(newData, id, value);
473
+ setFormData(newData);
407
474
  }
408
475
  }
409
- const isLoading = (instanceId && !formData && !document) || !form || !sanitizedObject;
476
+ const isLoading = (instanceId && !formDataRef.current && !document) || !form || !sanitizedObject;
410
477
  const status = error ? 'error' : isLoading ? 'loading' : 'ready';
478
+ // Compose a header renderer that injects the saving indicator into the rendered header
479
+ const composedRenderHeader = (props) => {
480
+ if (renderHeader) {
481
+ return renderHeader({ ...props, autosaving: !!form?.autosaveActionId && isSaving });
482
+ }
483
+ return React.createElement(Header, { ...props, autosaving: !!form?.autosaveActionId && isSaving });
484
+ };
411
485
  const onDiscardChanges = onDiscardChangesOverride
412
486
  ? onDiscardChangesOverride
413
487
  : async () => {
@@ -426,7 +500,7 @@ function FormRendererContainer(props) {
426
500
  padding: '0px',
427
501
  border: !isLoading ? '1px solid #dbe0e4' : undefined,
428
502
  ...sx,
429
- } }, !isLoading ? (React.createElement(React.Fragment, null, form && sanitizedObject && (React.createElement(FormRenderer, { onSubmit: onSubmit ? (data) => onSubmit(data, saveHandler) : saveHandler, onSubmitError: onSubmitError, onDiscardChanges: onDiscardChanges, richTextEditor: richTextEditor, fieldHeight: display?.fieldHeight ?? 'medium', value: formData, form: form, instance: dataType !== 'documents' ? instance : document, onChange: onChange, associatedObject: associatedObject, renderHeader: renderHeader, renderBody: renderBody, renderFooter: document && !hasDocumentUpdateAccess ? () => React.createElement(React.Fragment, null) : renderFooter })))) : (React.createElement(Box, { sx: { padding: '20px' } },
503
+ } }, !isLoading ? (React.createElement(React.Fragment, null, form && sanitizedObject && (React.createElement(FormRenderer, { onSubmit: onSubmit ? (data) => onSubmit(data, saveHandler) : saveHandler, onSubmitError: onSubmitError, onDiscardChanges: onDiscardChanges, richTextEditor: richTextEditor, fieldHeight: display?.fieldHeight ?? 'medium', value: formDataRef.current, form: form, instance: dataType !== 'documents' ? instance : document, onChange: onChange, onAutosave: onAutosave, associatedObject: associatedObject, renderHeader: composedRenderHeader, renderBody: renderBody, renderFooter: document && !hasDocumentUpdateAccess ? () => React.createElement(React.Fragment, null) : renderFooter })))) : (React.createElement(Box, { sx: { padding: '20px' } },
430
504
  React.createElement(Box, { display: 'flex', width: '100%', justifyContent: 'space-between' },
431
505
  React.createElement(Skeleton, { width: '78%', sx: { borderRadius: '8px', height: '40px' } }),
432
506
  React.createElement(Skeleton, { width: '20%', sx: { borderRadius: '8px', height: '40px' } })),
@@ -8,6 +8,7 @@ export type FooterProps = {
8
8
  submitButtonLabel?: string;
9
9
  discardChangesButtonLabel?: string;
10
10
  sx?: SxProps;
11
+ disableDiscardChanges?: boolean;
11
12
  };
12
13
  export declare const Footer: React.FC<FooterProps>;
13
14
  export type FooterActionsProps = Omit<FooterProps, 'sx'>;
@@ -22,12 +22,13 @@ export const Footer = (props) => {
22
22
  alignItems: 'center',
23
23
  borderTop: action?.type !== 'delete' ? '1px solid #f4f6f8' : 'none',
24
24
  borderRadius: '0px 0px 6px 6px',
25
+ zIndex: 3,
25
26
  ...sx,
26
27
  } },
27
28
  React.createElement(FooterActions, { ...props })));
28
29
  };
29
30
  export const FooterActions = (props) => {
30
- const { action, onDiscardChanges, onSubmit, submitButtonLabel, discardChangesButtonLabel } = props;
31
+ const { action, onDiscardChanges, onSubmit, submitButtonLabel, discardChangesButtonLabel, disableDiscardChanges } = props;
31
32
  const { width } = useContext(FormContext);
32
33
  const [loading, setLoading] = React.useState(false);
33
34
  const handleSubmit = async () => {
@@ -45,7 +46,7 @@ export const FooterActions = (props) => {
45
46
  });
46
47
  const { isXs } = breakpoints;
47
48
  return (React.createElement(React.Fragment, null,
48
- React.createElement(Button, { onClick: onDiscardChanges, variant: "outlined", sx: {
49
+ !disableDiscardChanges && (React.createElement(Button, { onClick: onDiscardChanges, variant: "outlined", sx: {
49
50
  margin: '5px',
50
51
  marginX: isXs ? '0px' : undefined,
51
52
  color: 'black',
@@ -55,7 +56,7 @@ export const FooterActions = (props) => {
55
56
  backgroundColor: '#f2f4f7',
56
57
  border: '1px solid rgb(206, 212, 218)',
57
58
  },
58
- } }, discardChangesButtonLabel),
59
+ } }, discardChangesButtonLabel)),
59
60
  React.createElement(LoadingButton, { onClick: handleSubmit, variant: "contained", sx: {
60
61
  lineHeight: '2.75',
61
62
  margin: '5px 0 5px 5px',
@@ -15,7 +15,8 @@ type FormContextType = {
15
15
  setExpandedSections?: React.Dispatch<React.SetStateAction<ExpandedSection[]>>;
16
16
  setExpandAll?: React.Dispatch<React.SetStateAction<boolean | undefined | null>>;
17
17
  parameters?: InputParameter[];
18
- handleChange: (name: string, value: unknown) => void;
18
+ handleChange: (name: string, value: unknown) => void | Promise<void>;
19
+ onAutosave?: (fieldId: string) => void | Promise<void>;
19
20
  fieldHeight?: 'small' | 'medium';
20
21
  triggerFieldReset?: boolean;
21
22
  showSubmitError?: boolean;
@@ -7,7 +7,7 @@ import FieldWrapper from '../FieldWrapper';
7
7
  import { getPrefixedUrl, isOptionEqualToValue } from '../utils';
8
8
  function AddressFields(props) {
9
9
  const { entry, readOnly, entryId, fieldDefinition } = props;
10
- const { getValues, instance, errors, handleChange, fieldHeight, parameters } = useFormContext();
10
+ const { getValues, instance, errors, handleChange, onAutosave, fieldHeight, parameters } = useFormContext();
11
11
  const apiServices = useApiServices();
12
12
  const addressObject = entryId.split('.')[0];
13
13
  const addressField = entryId.split('.')[1];
@@ -22,24 +22,41 @@ function AddressFields(props) {
22
22
  params: { query: query },
23
23
  });
24
24
  };
25
- const handleAddressChange = (name, value) => {
26
- if (addressField === 'line1' && typeof value === 'object' && value.line1) {
27
- const addressKeys = ['line1', 'city', 'county', 'state', 'zipCode'];
28
- addressKeys.forEach((key) => {
29
- const fullKey = `${addressObject}.${key}`;
30
- if (parameters?.some((p) => p.id === fullKey)) {
31
- const fieldValue = value[key];
32
- handleChange(fullKey, fieldValue);
25
+ const handleAddressChange = async (name, value) => {
26
+ try {
27
+ if (addressField === 'line1' && typeof value === 'object' && value.line1) {
28
+ const addressKeys = ['line1', 'city', 'county', 'state', 'zipCode'];
29
+ // Await each handleChange sequentially to ensure proper order
30
+ for (const key of addressKeys) {
31
+ const fullKey = `${addressObject}.${key}`;
32
+ if (parameters?.some((p) => p.id === fullKey)) {
33
+ const fieldValue = value[key];
34
+ await handleChange(fullKey, fieldValue);
35
+ }
33
36
  }
34
- });
37
+ // Autosave immediately after autocomplete fills all fields
38
+ try {
39
+ await onAutosave?.(entryId);
40
+ }
41
+ catch (error) {
42
+ console.error('Autosave failed:', error);
43
+ }
44
+ }
45
+ else {
46
+ await handleChange(name, value);
47
+ }
35
48
  }
36
- else {
37
- handleChange(name, value);
49
+ catch (error) {
50
+ console.error('Failed to update field:', error);
38
51
  }
39
52
  };
40
53
  const addressErrors = errors?.[addressObject];
41
54
  const addressFieldError = addressErrors?.[addressField];
42
- return (React.createElement(FieldWrapper, { inputId: entryId, inputType: "string", label: display?.label || 'default', description: !readOnly ? display?.description : undefined, tooltip: display?.tooltip, value: fieldValue, maxLength: 'maxLength' in validation ? validation?.maxLength : 0, required: entry.display?.required || false, showCharCount: !readOnly && display?.charCount, viewOnly: !!readOnly, prefix: display?.prefix, suffix: display?.suffix }, !readOnly ? (React.createElement(FormField, { property: fieldDefinition, defaultValue: fieldValue, onChange: handleAddressChange, isMultiLineText: !!display?.rowCount, readOnly: entry.type === 'readonlyField', ...(addressField === 'line1' && { queryAddresses }), mask: validation?.mask, placeholder: display?.placeholder, isOptionEqualToValue: isOptionEqualToValue, size: fieldHeight, error: !!addressFieldError, errorMessage: addressFieldError?.message, additionalProps: {
55
+ return (React.createElement(FieldWrapper, { inputId: entryId, inputType: "string", label: display?.label || 'default', description: !readOnly ? display?.description : undefined, tooltip: display?.tooltip, value: fieldValue, maxLength: 'maxLength' in validation ? validation?.maxLength : 0, required: entry.display?.required || false, showCharCount: !readOnly && display?.charCount, viewOnly: !!readOnly, prefix: display?.prefix, suffix: display?.suffix }, !readOnly ? (React.createElement(FormField, { property: fieldDefinition, defaultValue: fieldValue, onChange: handleAddressChange, onBlur: () => {
56
+ onAutosave?.(entryId)?.catch((error) => {
57
+ console.error('Autosave failed:', error);
58
+ });
59
+ }, isMultiLineText: !!display?.rowCount, readOnly: entry.type === 'readonlyField', ...(addressField === 'line1' && { queryAddresses }), mask: validation?.mask, placeholder: display?.placeholder, isOptionEqualToValue: isOptionEqualToValue, size: fieldHeight, error: !!addressFieldError, errorMessage: addressFieldError?.message, additionalProps: {
43
60
  ...(display?.description && {
44
61
  inputProps: {
45
62
  'aria-describedby': `${entryId}-description`,
@@ -87,7 +87,7 @@ export const ActionDialog = (props) => {
87
87
  borderBottom: action.type === 'delete' ? undefined : '1px solid #e9ecef',
88
88
  } },
89
89
  action && hasAccess && !loading ? action?.name : '',
90
- React.createElement(IconButton, { sx: styles.closeIcon, onClick: onClose },
90
+ React.createElement(IconButton, { sx: styles.closeIcon, onClick: onClose, "aria-label": "Close" },
91
91
  React.createElement(Close, { fontSize: "small" })),
92
92
  formHeaderProps.hasAccordions && React.createElement(AccordionActions, { ...formHeaderProps })));
93
93
  }, renderBody: (bodyProps) => (React.createElement(DialogContent, { sx: {
@@ -6,9 +6,6 @@ export type ObjectPropertyInputProps = {
6
6
  criteria?: object;
7
7
  viewLayout?: ViewLayoutEntityReference;
8
8
  entry: InputField | InputParameterReference | ReadonlyField;
9
- createActionId?: string;
10
- updateActionId?: string;
11
- deleteActionId?: string;
12
9
  };
13
10
  declare const RepeatableField: (props: ObjectPropertyInputProps) => React.JSX.Element;
14
11
  export default RepeatableField;
@@ -1,5 +1,5 @@
1
1
  import { useApiServices, useNotification, } from '@evoke-platform/context';
2
- import { get, isEqual, pick, startCase } from 'lodash';
2
+ import { get, isEqual, omit, startCase } from 'lodash';
3
3
  import { DateTime } from 'luxon';
4
4
  import React, { useCallback, useEffect, useState } from 'react';
5
5
  import sift from 'sift';
@@ -34,7 +34,7 @@ const styles = {
34
34
  },
35
35
  };
36
36
  const RepeatableField = (props) => {
37
- const { fieldDefinition, canUpdateProperty, criteria, viewLayout, entry, createActionId, updateActionId, deleteActionId, } = props;
37
+ const { fieldDefinition, canUpdateProperty, criteria, viewLayout, entry } = props;
38
38
  const { fetchedOptions, setFetchedOptions, instance, width } = useFormContext();
39
39
  const { isBelow } = useWidgetSize({
40
40
  scroll: false,
@@ -62,9 +62,9 @@ const RepeatableField = (props) => {
62
62
  showAlert: false,
63
63
  isError: false,
64
64
  });
65
- const createAction = relatedObject?.actions?.find((item) => item.id === createActionId);
66
- const updateAction = relatedObject?.actions?.find((item) => item.id === updateActionId);
67
- const deleteAction = relatedObject?.actions?.find((item) => item.id === deleteActionId);
65
+ const createAction = relatedObject?.actions?.find((item) => item.id === entry.display?.createActionId);
66
+ const updateAction = relatedObject?.actions?.find((item) => item.id === entry.display?.updateActionId);
67
+ const deleteAction = relatedObject?.actions?.find((item) => item.id === entry.display?.deleteActionId);
68
68
  function getForm(setForm, action, formId) {
69
69
  if (formId || action?.defaultFormId) {
70
70
  apiServices
@@ -209,11 +209,11 @@ const RepeatableField = (props) => {
209
209
  }, [fetchCriteriaObjects, relatedObject]);
210
210
  useEffect(() => {
211
211
  if (createAction && !createForm)
212
- getForm(setCreateForm, createAction); // TODO: pass entry.display?.createForm as a third argument
212
+ getForm(setCreateForm, createAction, entry.display?.createFormId);
213
213
  if (updateAction && !updateForm)
214
- getForm(setUpdateForm, updateAction); // TODO: pass entry.display?.updateForm as a third argument
214
+ getForm(setUpdateForm, updateAction, entry.display?.updateFormId);
215
215
  if (deleteAction && !deleteForm)
216
- getForm(setDeleteForm, deleteAction); // TODO: pass entry.display?.deleteForm as a third argument
216
+ getForm(setDeleteForm, deleteAction, entry.display?.deleteFormId);
217
217
  }, [entry.display, createAction, updateAction, deleteAction]);
218
218
  useEffect(() => {
219
219
  if (relatedObject?.rootObjectId) {
@@ -269,10 +269,10 @@ const RepeatableField = (props) => {
269
269
  if (fieldDefinition.objectId && canUpdateProperty && !fetchedOptions[`${fieldDefinition.id}HasCreateAction`]) {
270
270
  apiServices
271
271
  .get(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances/checkAccess`), {
272
- params: { action: 'execute', field: '_create', scope: 'data' },
272
+ params: { action: 'execute', field: entry.display?.createActionId, scope: 'data' },
273
273
  })
274
274
  .then((checkAccess) => {
275
- const action = relatedObject.actions?.find((item) => item.id === '_create');
275
+ const action = relatedObject.actions?.find((item) => item.id === entry.display?.createActionId);
276
276
  if (action && fieldDefinition.relatedPropertyId) {
277
277
  const { relatedObjectProperty, criteria } = retrieveCriteria(fieldDefinition.relatedPropertyId, action, relatedObject);
278
278
  if (!criteria || JSON.stringify(criteria).includes('{{{input.') || !relatedObjectProperty) {
@@ -346,17 +346,21 @@ const RepeatableField = (props) => {
346
346
  }, variant: "text", onClick: () => setReloadOnErrorTrigger((prevState) => !prevState) }, "Retry")));
347
347
  const save = async (input) => {
348
348
  const action = relatedObject?.actions?.find((a) => a.id ===
349
- (dialogType === 'create' ? createActionId : dialogType === 'update' ? updateActionId : deleteActionId));
349
+ (dialogType === 'create'
350
+ ? entry.display?.createActionId
351
+ : dialogType === 'update'
352
+ ? entry.display?.updateActionId
353
+ : entry.display?.deleteActionId));
350
354
  // when save is called we know that fieldDefinition is a parameter and fieldDefinition.objectId is defined
351
355
  input = await formatSubmission(input, apiServices, fieldDefinition.objectId, selectedInstanceId, action?.type === 'update' ? updateForm : undefined);
352
- if (action?.type === 'create' && createActionId) {
356
+ if (action?.type === 'create' && entry.display?.createActionId) {
353
357
  const updatedInput = {
354
358
  ...input,
355
359
  [fieldDefinition?.relatedPropertyId]: { id: instance?.id },
356
360
  };
357
361
  try {
358
362
  const instance = await apiServices.post(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances/actions`), {
359
- actionId: createActionId,
363
+ actionId: entry.display?.createActionId,
360
364
  input: updatedInput,
361
365
  });
362
366
  const hasAccess = fieldDefinition?.relatedPropertyId && fieldDefinition.relatedPropertyId in instance;
@@ -379,8 +383,8 @@ const RepeatableField = (props) => {
379
383
  try {
380
384
  const response = await apiServices.post(getPrefixedUrl(`/objects/${relatedObjectId}/instances/${selectedInstanceId}/actions`), {
381
385
  actionId: `_${action?.type}`,
382
- input: pick(input, relatedObject?.properties
383
- ?.filter((property) => !property.formula && property.type !== 'collection')
386
+ input: omit(input, relatedObject?.properties
387
+ ?.filter((property) => property.formula || property.type === 'collection')
384
388
  .map((property) => property.id) ?? []),
385
389
  });
386
390
  if (response && relatedObject && instance) {
@@ -519,12 +523,12 @@ const RepeatableField = (props) => {
519
523
  users?.find((user) => get(relatedInstance, `${prop.id.split('.')[0]}.id`) === user.id)?.status === 'Inactive' &&
520
524
  ' (Inactive)')))))),
521
525
  canUpdateProperty && (React.createElement(Box, { sx: { mt: 2, display: 'flex', gap: 1 } },
522
- React.createElement(IconButton, { onClick: () => editRow(relatedInstance.id) },
526
+ entry.display?.updateActionId && (React.createElement(IconButton, { onClick: () => editRow(relatedInstance.id) },
523
527
  React.createElement(Tooltip, { title: "Edit" },
524
- React.createElement(Edit, null))),
525
- React.createElement(IconButton, { onClick: () => deleteRow(relatedInstance.id) },
528
+ React.createElement(Edit, null)))),
529
+ entry.display?.deleteActionId && (React.createElement(IconButton, { onClick: () => deleteRow(relatedInstance.id) },
526
530
  React.createElement(Tooltip, { title: "Delete" },
527
- React.createElement(TrashCan, { sx: { ':hover': { color: '#A12723' } } }))))))))))) : (React.createElement(TableContainer, { sx: {
531
+ React.createElement(TrashCan, { sx: { ':hover': { color: '#A12723' } } })))))))))))) : (React.createElement(TableContainer, { sx: {
528
532
  borderRadius: '6px',
529
533
  border: '1px solid #919EAB3D',
530
534
  boxShadow: 'none',
@@ -544,7 +548,7 @@ const RepeatableField = (props) => {
544
548
  cursor: 'pointer',
545
549
  },
546
550
  }
547
- : {}, onClick: updateActionId &&
551
+ : {}, onClick: entry.display?.updateActionId &&
548
552
  canUpdateProperty &&
549
553
  prop.id === 'name'
550
554
  ? () => editRow(relatedInstance.id)
@@ -554,19 +558,19 @@ const RepeatableField = (props) => {
554
558
  users?.find((user) => get(relatedInstance, `${prop.id.split('.')[0]}.id`) === user.id)?.status === 'Inactive' && (React.createElement("span", null, ' (Inactive)'))))));
555
559
  }),
556
560
  canUpdateProperty && (React.createElement(TableCell, { sx: { width: '80px' } },
557
- updateActionId && (React.createElement(IconButton, { "aria-label": `edit-collection-instance-${index}`, onClick: () => editRow(relatedInstance.id) },
561
+ entry.display?.updateActionId && (React.createElement(IconButton, { "aria-label": `edit-collection-instance-${index}`, onClick: () => editRow(relatedInstance.id) },
558
562
  React.createElement(Tooltip, { title: "Edit" },
559
563
  React.createElement(Edit, null)))),
560
- React.createElement(IconButton, { "aria-label": `delete-collection-instance-${index}`, onClick: () => deleteRow(relatedInstance.id) },
564
+ entry.display?.deleteActionId && (React.createElement(IconButton, { "aria-label": `delete-collection-instance-${index}`, onClick: () => deleteRow(relatedInstance.id) },
561
565
  React.createElement(Tooltip, { title: "Delete" },
562
- React.createElement(TrashCan, { sx: { ':hover': { color: '#A12723' } } })))))))))))),
563
- hasCreateAction && createActionId && (React.createElement(Button, { variant: "contained", sx: styles.addButton, onClick: addRow, "aria-label": 'Add' }, "Add"))),
566
+ React.createElement(TrashCan, { sx: { ':hover': { color: '#A12723' } } }))))))))))))),
567
+ hasCreateAction && entry.display?.createActionId && (React.createElement(Button, { variant: "contained", sx: styles.addButton, disabled: !createAction, onClick: addRow, "aria-label": 'Add' }, "Add"))),
564
568
  relatedObject && openDialog && (React.createElement(ActionDialog, { object: relatedObject, open: openDialog, onClose: () => setOpenDialog(false), onSubmit: save, action: relatedObject?.actions?.find((a) => a.id ===
565
569
  (dialogType === 'create'
566
- ? createActionId
570
+ ? entry.display?.createActionId
567
571
  : dialogType === 'update'
568
- ? updateActionId
569
- : deleteActionId)), relatedFormId: dialogType === 'create'
572
+ ? entry.display?.updateActionId
573
+ : entry.display?.deleteActionId)), relatedFormId: dialogType === 'create'
570
574
  ? createForm?.id
571
575
  : dialogType === 'update'
572
576
  ? updateForm?.id
@@ -8,7 +8,7 @@ import { addressProperties, getPrefixedUrl } from '../utils';
8
8
  export default function Criteria(props) {
9
9
  const { value, canUpdateProperty, fieldDefinition, error } = props;
10
10
  const apiServices = useApiServices();
11
- const { fetchedOptions, setFetchedOptions, handleChange } = useFormContext();
11
+ const { fetchedOptions, setFetchedOptions, handleChange, onAutosave } = useFormContext();
12
12
  const [loadingError, setLoadingError] = useState(false);
13
13
  const [loading, setLoading] = useState(false);
14
14
  const [properties, setProperties] = useState(fetchedOptions[`${fieldDefinition.id}Options`] || []);
@@ -57,9 +57,22 @@ export default function Criteria(props) {
57
57
  useEffect(() => {
58
58
  fetchProperties();
59
59
  }, [fetchProperties]);
60
- const handleUpdate = (criteria) => {
60
+ const handleUpdate = async (criteria) => {
61
61
  if (criteria || value) {
62
- handleChange(fieldDefinition.id, criteria ?? null);
62
+ const newValue = criteria ?? null;
63
+ try {
64
+ await handleChange(fieldDefinition.id, newValue);
65
+ }
66
+ catch (error) {
67
+ console.error('Failed to update field:', error);
68
+ return;
69
+ }
70
+ try {
71
+ await onAutosave?.(fieldDefinition.id);
72
+ }
73
+ catch (error) {
74
+ console.error('Autosave failed:', error);
75
+ }
63
76
  }
64
77
  };
65
78
  if (loadingError) {
@@ -12,7 +12,7 @@ import { DocumentList } from './DocumentList';
12
12
  export const Document = (props) => {
13
13
  const { id, canUpdateProperty, error, value, validate, hasDescription } = props;
14
14
  const apiServices = useApiServices();
15
- const { fetchedOptions, setFetchedOptions, object, handleChange, instance } = useFormContext();
15
+ const { fetchedOptions, setFetchedOptions, object, handleChange, onAutosave: onAutosave, instance, } = useFormContext();
16
16
  const [snackbarError, setSnackbarError] = useState();
17
17
  const [documents, setDocuments] = useState();
18
18
  const [hasUpdatePermission, setHasUpdatePermission] = useState(fetchedOptions[`${id}UpdatePermission`]);
@@ -49,13 +49,25 @@ export const Document = (props) => {
49
49
  checkPermissions();
50
50
  }, [checkPermissions]);
51
51
  const handleUpload = async (files) => {
52
+ // Store File objects in form state - they will be uploaded during autosave via formatSubmission()
52
53
  const newDocuments = [...(documents ?? []), ...(files ?? [])];
53
54
  setDocuments(newDocuments);
54
- handleChange(id, newDocuments);
55
+ try {
56
+ await handleChange(id, newDocuments);
57
+ }
58
+ catch (error) {
59
+ console.error('Failed to update field:', error);
60
+ return;
61
+ }
62
+ try {
63
+ await onAutosave?.(id);
64
+ }
65
+ catch (error) {
66
+ console.error('Autosave failed:', error);
67
+ }
55
68
  };
56
69
  const uploadDisabled = !!validate?.maxDocuments && (documents?.length ?? 0) >= validate.maxDocuments;
57
70
  const { getRootProps, getInputProps, open, fileRejections } = useDropzone({
58
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
59
71
  onDrop: (files) => handleUpload(files),
60
72
  disabled: uploadDisabled,
61
73
  accept: validate?.allowedFileExtensions
@@ -109,7 +121,7 @@ export const Document = (props) => {
109
121
  } }, validate?.maxDocuments === 1
110
122
  ? `Maximum size is ${formattedMaxSize}.`
111
123
  : `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' } })) : (React.createElement(DocumentList, { id: id, handleChange: handleChange, value: value, setSnackbarError: (type, message) => setSnackbarError({ message, type }), canUpdateProperty: canUpdateProperty && !!hasUpdatePermission })),
124
+ 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, onAutosave: onAutosave, value: value, setSnackbarError: (type, message) => setSnackbarError({ message, type }), canUpdateProperty: canUpdateProperty && !!hasUpdatePermission })),
113
125
  React.createElement(Snackbar, { open: !!snackbarError?.message, handleClose: () => setSnackbarError(null), message: snackbarError?.message, error: snackbarError?.type === 'error' }),
114
126
  errors.length > 0 && (React.createElement(Box, { display: 'flex', alignItems: 'center' },
115
127
  React.createElement(InfoRounded, { sx: { fontSize: '.75rem', marginRight: '3px', color: '#D3271B' } }),
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { SavedDocumentReference } from '../../types';
3
3
  type DocumentListProps = {
4
4
  handleChange: (propertyId: string, value: (File | SavedDocumentReference)[] | undefined) => void;
5
+ onAutosave?: (fieldId: string) => void | Promise<void>;
5
6
  id: string;
6
7
  canUpdateProperty: boolean;
7
8
  value: (File | SavedDocumentReference)[] | undefined;
@@ -24,7 +24,7 @@ const viewableFileTypes = [
24
24
  'text/plain',
25
25
  ];
26
26
  export const DocumentList = (props) => {
27
- const { handleChange, id, canUpdateProperty, value: documents, setSnackbarError } = props;
27
+ const { handleChange, onAutosave, id, canUpdateProperty, value: documents, setSnackbarError } = props;
28
28
  const apiServices = useApiServices();
29
29
  const { fetchedOptions, setFetchedOptions, object, instance } = useFormContext();
30
30
  const [hasViewPermission, setHasViewPermission] = useState(fetchedOptions[`${id}ViewPermission`] ?? true);
@@ -88,9 +88,22 @@ export const DocumentList = (props) => {
88
88
  };
89
89
  const isFile = (doc) => doc instanceof File;
90
90
  const fileExists = (doc) => savedDocuments?.find((d) => d.id === doc.id);
91
- const handleRemove = (index) => {
91
+ const handleRemove = async (index) => {
92
92
  const updatedDocuments = documents?.filter((_, i) => i !== index) ?? [];
93
- handleChange(id, updatedDocuments.length === 0 ? undefined : updatedDocuments);
93
+ const newValue = updatedDocuments.length === 0 ? undefined : updatedDocuments;
94
+ try {
95
+ await handleChange(id, newValue);
96
+ }
97
+ catch (error) {
98
+ console.error('Failed to update field:', error);
99
+ return;
100
+ }
101
+ try {
102
+ await onAutosave?.(id);
103
+ }
104
+ catch (error) {
105
+ console.error('Autosave failed:', error);
106
+ }
94
107
  };
95
108
  const openDocument = async (index) => {
96
109
  const doc = documents?.[index];