@evoke-platform/ui-components 1.10.0-testing.10 → 1.10.0-testing.12

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 (26) hide show
  1. package/dist/published/components/custom/FormV2/FormRenderer.d.ts +2 -1
  2. package/dist/published/components/custom/FormV2/FormRenderer.js +3 -1
  3. package/dist/published/components/custom/FormV2/FormRendererContainer.js +82 -13
  4. package/dist/published/components/custom/FormV2/components/Footer.d.ts +1 -0
  5. package/dist/published/components/custom/FormV2/components/Footer.js +3 -3
  6. package/dist/published/components/custom/FormV2/components/FormContext.d.ts +2 -1
  7. package/dist/published/components/custom/FormV2/components/FormFieldTypes/AddressFields.js +30 -13
  8. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +1 -1
  9. package/dist/published/components/custom/FormV2/components/FormFieldTypes/Criteria.js +16 -3
  10. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +16 -4
  11. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.d.ts +1 -0
  12. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.js +16 -3
  13. package/dist/published/components/custom/FormV2/components/FormFieldTypes/Image.js +31 -5
  14. package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +15 -3
  15. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +70 -18
  16. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/RelatedObjectInstance.js +37 -15
  17. package/dist/published/components/custom/FormV2/components/Header.d.ts +1 -0
  18. package/dist/published/components/custom/FormV2/components/Header.js +42 -4
  19. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +31 -6
  20. package/dist/published/components/custom/FormV2/components/utils.js +2 -0
  21. package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +449 -1
  22. package/dist/published/components/custom/FormV2/tests/test-data.d.ts +1 -0
  23. package/dist/published/components/custom/FormV2/tests/test-data.js +138 -0
  24. package/dist/published/stories/FormRenderer.stories.d.ts +8 -4
  25. package/dist/published/theme/hooks.d.ts +4 -3
  26. package/package.json +1 -1
@@ -16,7 +16,8 @@ export type FormRendererProps = BaseProps & {
16
16
  form: EvokeForm;
17
17
  title?: string | React.ReactNode;
18
18
  instance?: ObjectInstance | Document;
19
- onChange: (id: string, value: unknown) => void;
19
+ onChange: (id: string, value: unknown) => void | Promise<void>;
20
+ onAutosave?: (fieldId: string) => void | Promise<void>;
20
21
  associatedObject?: {
21
22
  instanceId?: string;
22
23
  propertyId?: string;
@@ -12,7 +12,7 @@ import { assignIdsToSectionsAndRichText, convertDocToParameters, convertProperti
12
12
  import { handleValidation } from './components/ValidationFiles/Validation';
13
13
  import ValidationErrors from './components/ValidationFiles/ValidationErrors';
14
14
  const FormRendererInternal = (props) => {
15
- const { onSubmit, onDiscardChanges, onSubmitError: onSubmitErrorOverride, value, fieldHeight, richTextEditor, form, instance, onChange, associatedObject, renderHeader, renderBody, renderFooter, } = props;
15
+ const { onSubmit, onDiscardChanges, onSubmitError: onSubmitErrorOverride, value, fieldHeight, richTextEditor, form, instance, onChange, onAutosave, associatedObject, renderHeader, renderBody, renderFooter, } = props;
16
16
  const { entries, name: title, objectId, actionId, display } = form;
17
17
  const { register, unregister, setValue, reset, handleSubmit, formState: { errors, isSubmitted }, getValues, } = useForm({
18
18
  defaultValues: value,
@@ -165,6 +165,7 @@ const FormRendererInternal = (props) => {
165
165
  action,
166
166
  discardChangesButtonLabel: 'Discard Changes',
167
167
  submitButtonLabel: display?.submitLabel ?? 'Submit',
168
+ disableDiscardChanges: !!form?.autosaveActionId,
168
169
  };
169
170
  return (React.createElement(Box, { ref: containerRef },
170
171
  React.createElement(FormContext.Provider, { value: {
@@ -182,6 +183,7 @@ const FormRendererInternal = (props) => {
182
183
  parameters,
183
184
  fieldHeight,
184
185
  handleChange: onChange,
186
+ onAutosave,
185
187
  triggerFieldReset,
186
188
  showSubmitError: isSubmitted,
187
189
  associatedObject,
@@ -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) => {
@@ -189,6 +210,8 @@ function FormRendererContainer(props) {
189
210
  if (document && objectId) {
190
211
  const defaultValues = await getDefaultValues(convertDocToEntries(document), document);
191
212
  setFormData(defaultValues);
213
+ // Deep clone to avoid reference issues
214
+ setLastSavedData(cloneDeep(defaultValues));
192
215
  if (!form) {
193
216
  setForm({
194
217
  id: 'documentForm',
@@ -201,6 +224,8 @@ function FormRendererContainer(props) {
201
224
  else if (form && (instance || !instanceId)) {
202
225
  const defaultValues = await getDefaultValues(form.entries, instance || {});
203
226
  setFormData(defaultValues);
227
+ // Deep clone to avoid reference issues
228
+ setLastSavedData(cloneDeep(defaultValues));
204
229
  }
205
230
  };
206
231
  getInitialValues();
@@ -351,7 +376,7 @@ function FormRendererContainer(props) {
351
376
  for (const { fieldId, fieldValue } of defaultValuesArray) {
352
377
  const parameter = parameters?.find((param) => param.id === fieldId);
353
378
  if (parameter?.type === 'object') {
354
- 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);
355
380
  for (const field of dependentFields) {
356
381
  set(result, field.fieldId, field.fieldValue);
357
382
  }
@@ -382,6 +407,45 @@ function FormRendererContainer(props) {
382
407
  await processEntries(entries);
383
408
  return result;
384
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;
385
449
  async function onChange(id, value) {
386
450
  const parameter = parameters?.find((param) => param.id === id);
387
451
  const entries = getUnnestedEntries(form.entries);
@@ -392,7 +456,7 @@ function FormRendererContainer(props) {
392
456
  if (parameter) {
393
457
  if (parameter.type === 'object' && parameters && parameters.length > 0) {
394
458
  // On change of a related object, update default values dependent on that object
395
- 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);
396
460
  for (const field of dependentFields) {
397
461
  onChange(field.fieldId, field.fieldValue);
398
462
  }
@@ -403,16 +467,21 @@ function FormRendererContainer(props) {
403
467
  value = value.value ? value.value : value;
404
468
  }
405
469
  }
406
- if (!isEqual(value, get(formData, id))) {
407
- setFormData((prev) => {
408
- const newData = { ...prev };
409
- set(newData, id, value);
410
- return newData;
411
- });
470
+ if (!isEqual(value, get(formDataRef.current, id))) {
471
+ const newData = { ...formDataRef.current };
472
+ set(newData, id, value);
473
+ setFormData(newData);
412
474
  }
413
475
  }
414
- const isLoading = (instanceId && !formData && !document) || !form || !sanitizedObject;
476
+ const isLoading = (instanceId && !formDataRef.current && !document) || !form || !sanitizedObject;
415
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
+ };
416
485
  const onDiscardChanges = onDiscardChangesOverride
417
486
  ? onDiscardChangesOverride
418
487
  : async () => {
@@ -431,7 +500,7 @@ function FormRendererContainer(props) {
431
500
  padding: '0px',
432
501
  border: !isLoading ? '1px solid #dbe0e4' : undefined,
433
502
  ...sx,
434
- } }, !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' } },
435
504
  React.createElement(Box, { display: 'flex', width: '100%', justifyContent: 'space-between' },
436
505
  React.createElement(Skeleton, { width: '78%', sx: { borderRadius: '8px', height: '40px' } }),
437
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'>;
@@ -28,7 +28,7 @@ export const Footer = (props) => {
28
28
  React.createElement(FooterActions, { ...props })));
29
29
  };
30
30
  export const FooterActions = (props) => {
31
- const { action, onDiscardChanges, onSubmit, submitButtonLabel, discardChangesButtonLabel } = props;
31
+ const { action, onDiscardChanges, onSubmit, submitButtonLabel, discardChangesButtonLabel, disableDiscardChanges } = props;
32
32
  const { width } = useContext(FormContext);
33
33
  const [loading, setLoading] = React.useState(false);
34
34
  const handleSubmit = async () => {
@@ -46,7 +46,7 @@ export const FooterActions = (props) => {
46
46
  });
47
47
  const { isXs } = breakpoints;
48
48
  return (React.createElement(React.Fragment, null,
49
- React.createElement(Button, { onClick: onDiscardChanges, variant: "outlined", sx: {
49
+ !disableDiscardChanges && (React.createElement(Button, { onClick: onDiscardChanges, variant: "outlined", sx: {
50
50
  margin: '5px',
51
51
  marginX: isXs ? '0px' : undefined,
52
52
  color: 'black',
@@ -56,7 +56,7 @@ export const FooterActions = (props) => {
56
56
  backgroundColor: '#f2f4f7',
57
57
  border: '1px solid rgb(206, 212, 218)',
58
58
  },
59
- } }, discardChangesButtonLabel),
59
+ } }, discardChangesButtonLabel)),
60
60
  React.createElement(LoadingButton, { onClick: handleSubmit, variant: "contained", sx: {
61
61
  lineHeight: '2.75',
62
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`,
@@ -564,7 +564,7 @@ const RepeatableField = (props) => {
564
564
  entry.display?.deleteActionId && (React.createElement(IconButton, { "aria-label": `delete-collection-instance-${index}`, onClick: () => deleteRow(relatedInstance.id) },
565
565
  React.createElement(Tooltip, { title: "Delete" },
566
566
  React.createElement(TrashCan, { sx: { ':hover': { color: '#A12723' } } }))))))))))))),
567
- hasCreateAction && entry.display?.createActionId && (React.createElement(Button, { variant: "contained", sx: styles.addButton, onClick: addRow, "aria-label": 'Add' }, "Add"))),
567
+ hasCreateAction && entry.display?.createActionId && (React.createElement(Button, { variant: "contained", sx: styles.addButton, disabled: !createAction, onClick: addRow, "aria-label": 'Add' }, "Add"))),
568
568
  relatedObject && openDialog && (React.createElement(ActionDialog, { object: relatedObject, open: openDialog, onClose: () => setOpenDialog(false), onSubmit: save, action: relatedObject?.actions?.find((a) => a.id ===
569
569
  (dialogType === 'create'
570
570
  ? entry.display?.createActionId
@@ -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];
@@ -55,7 +55,7 @@ const styles = {
55
55
  };
56
56
  export const Image = (props) => {
57
57
  const { id, canUpdateProperty, error, value, hasDescription } = props;
58
- const { handleChange } = useFormContext();
58
+ const { handleChange, onAutosave: onAutosave } = useFormContext();
59
59
  const [image, setImage] = useState();
60
60
  useEffect(() => {
61
61
  if (typeof value === 'string') {
@@ -63,15 +63,41 @@ export const Image = (props) => {
63
63
  }
64
64
  }, [value]);
65
65
  const handleUpload = async (file) => {
66
+ // max file size 300KB
66
67
  if (file?.size && file.size <= 300000) {
67
- const dataUrl = file ? await blobToDataUrl(file) : null;
68
+ const dataUrl = await blobToDataUrl(file);
68
69
  setImage(dataUrl);
69
- handleChange(id, dataUrl);
70
+ try {
71
+ await handleChange(id, dataUrl);
72
+ }
73
+ catch (error) {
74
+ console.error('Failed to update field:', error);
75
+ return;
76
+ }
77
+ try {
78
+ await onAutosave?.(id);
79
+ }
80
+ catch (error) {
81
+ console.error('Autosave failed:', error);
82
+ }
70
83
  }
71
84
  };
72
- const handleRemove = (e) => {
85
+ const handleRemove = async (e) => {
73
86
  setImage(null);
74
- handleChange(id, '');
87
+ try {
88
+ await handleChange(id, '');
89
+ }
90
+ catch (error) {
91
+ console.error('Failed to update field:', error);
92
+ e.stopPropagation();
93
+ return;
94
+ }
95
+ try {
96
+ await onAutosave?.(id);
97
+ }
98
+ catch (error) {
99
+ console.error('Autosave failed:', error);
100
+ }
75
101
  e.stopPropagation();
76
102
  };
77
103
  const { getRootProps, getInputProps, open } = useDropzone({
@@ -6,7 +6,7 @@ import { Autocomplete, IconButton, Paper, TextField, Typography } from '../../..
6
6
  import { getPrefixedUrl, isOptionEqualToValue } from '../utils';
7
7
  const UserProperty = (props) => {
8
8
  const { id, error, value, readOnly, hasDescription } = props;
9
- const { fetchedOptions, setFetchedOptions, handleChange, fieldHeight } = useFormContext();
9
+ const { fetchedOptions, setFetchedOptions, handleChange, onAutosave: onAutosave, fieldHeight } = useFormContext();
10
10
  const [loadingOptions, setLoadingOptions] = useState(false);
11
11
  const apiServices = useApiServices();
12
12
  const [options, setOptions] = useState(fetchedOptions[`${id}Options`] || []);
@@ -40,9 +40,21 @@ const UserProperty = (props) => {
40
40
  });
41
41
  }
42
42
  }, [id]);
43
- function handleChangeUserProperty(id, value) {
43
+ async function handleChangeUserProperty(id, value) {
44
44
  const updatedValue = typeof value?.value === 'string' ? { name: value.label, id: value.value } : null;
45
- handleChange(id, updatedValue);
45
+ try {
46
+ await handleChange(id, updatedValue);
47
+ }
48
+ catch (error) {
49
+ console.error('Failed to update field:', error);
50
+ return; // Exit early if handleChange fails
51
+ }
52
+ try {
53
+ await onAutosave?.(id);
54
+ }
55
+ catch (error) {
56
+ console.error('Autosave failed:', error);
57
+ }
46
58
  }
47
59
  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
60
  if (e.key === 'Enter') {