@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.
- package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.js +1 -1
- package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.test.d.ts +1 -0
- package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.test.js +430 -0
- package/dist/published/components/custom/CriteriaBuilder/ValueEditor.js +19 -6
- package/dist/published/components/custom/Form/utils.js +1 -0
- package/dist/published/components/custom/FormV2/FormRenderer.d.ts +2 -1
- package/dist/published/components/custom/FormV2/FormRenderer.js +15 -3
- package/dist/published/components/custom/FormV2/FormRendererContainer.d.ts +0 -2
- package/dist/published/components/custom/FormV2/FormRendererContainer.js +92 -18
- package/dist/published/components/custom/FormV2/components/Footer.d.ts +1 -0
- package/dist/published/components/custom/FormV2/components/Footer.js +4 -3
- package/dist/published/components/custom/FormV2/components/FormContext.d.ts +2 -1
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/AddressFields.js +30 -13
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/ActionDialog.js +1 -1
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.d.ts +0 -3
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +31 -27
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/Criteria.js +16 -3
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +16 -4
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.d.ts +1 -0
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.js +16 -3
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/Image.js +32 -7
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +15 -3
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +70 -18
- package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/RelatedObjectInstance.js +37 -15
- package/dist/published/components/custom/FormV2/components/Header.d.ts +2 -0
- package/dist/published/components/custom/FormV2/components/Header.js +47 -6
- package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +33 -19
- package/dist/published/components/custom/FormV2/components/utils.js +4 -7
- package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +432 -4
- package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +651 -13
- package/dist/published/components/custom/FormV2/tests/test-data.d.ts +1 -0
- package/dist/published/components/custom/FormV2/tests/test-data.js +140 -0
- package/dist/published/stories/FormRenderer.stories.d.ts +8 -4
- package/dist/published/stories/FormRendererContainer.stories.d.ts +0 -10
- package/dist/published/stories/FormRendererContainer.stories.js +7 -3
- package/dist/published/stories/FormRendererData.js +3 -43
- package/dist/published/theme/hooks.d.ts +4 -3
- 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
|
|
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
|
-
|
|
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:
|
|
249
|
-
?.filter((property) =>
|
|
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:
|
|
260
|
-
?.filter((property) =>
|
|
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,
|
|
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,
|
|
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(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
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 && !
|
|
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:
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
const
|
|
32
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
212
|
+
getForm(setCreateForm, createAction, entry.display?.createFormId);
|
|
213
213
|
if (updateAction && !updateForm)
|
|
214
|
-
getForm(setUpdateForm, updateAction
|
|
214
|
+
getForm(setUpdateForm, updateAction, entry.display?.updateFormId);
|
|
215
215
|
if (deleteAction && !deleteForm)
|
|
216
|
-
getForm(setDeleteForm, deleteAction
|
|
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:
|
|
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 ===
|
|
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'
|
|
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:
|
|
383
|
-
?.filter((property) =>
|
|
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
|
-
|
|
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) {
|
package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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];
|