@evoke-platform/ui-components 1.13.0-dev.4 → 1.13.0-dev.6

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 (32) hide show
  1. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.d.ts +4 -4
  2. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.js +145 -72
  3. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.test.js +189 -67
  4. package/dist/published/components/custom/CriteriaBuilder/PropertyTree.d.ts +6 -6
  5. package/dist/published/components/custom/CriteriaBuilder/PropertyTree.js +12 -25
  6. package/dist/published/components/custom/CriteriaBuilder/PropertyTreeItem.d.ts +4 -5
  7. package/dist/published/components/custom/CriteriaBuilder/PropertyTreeItem.js +34 -22
  8. package/dist/published/components/custom/CriteriaBuilder/types.d.ts +2 -11
  9. package/dist/published/components/custom/CriteriaBuilder/utils.d.ts +6 -34
  10. package/dist/published/components/custom/CriteriaBuilder/utils.js +18 -89
  11. package/dist/published/components/custom/Form/FormComponents/DocumentComponent/Document.js +1 -1
  12. package/dist/published/components/custom/Form/FormComponents/DocumentComponent/DocumentList.js +6 -3
  13. package/dist/published/components/custom/Form/utils.d.ts +1 -0
  14. package/dist/published/components/custom/FormV2/FormRenderer.d.ts +1 -0
  15. package/dist/published/components/custom/FormV2/FormRendererContainer.d.ts +1 -0
  16. package/dist/published/components/custom/FormV2/FormRendererContainer.js +25 -22
  17. package/dist/published/components/custom/FormV2/components/FormContext.d.ts +1 -0
  18. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/ActionDialog.d.ts +1 -0
  19. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.d.ts +3 -2
  20. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +46 -10
  21. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.d.ts +4 -3
  22. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.js +29 -23
  23. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +4 -2
  24. package/dist/published/components/custom/FormV2/components/types.d.ts +6 -1
  25. package/dist/published/components/custom/FormV2/components/utils.d.ts +5 -7
  26. package/dist/published/components/custom/FormV2/components/utils.js +146 -69
  27. package/dist/published/components/custom/ViewDetailsV2/InstanceEntryRenderer.js +20 -8
  28. package/dist/published/stories/CriteriaBuilder.stories.js +70 -22
  29. package/dist/published/stories/FormRenderer.stories.d.ts +3 -0
  30. package/dist/published/stories/FormRendererContainer.stories.d.ts +5 -0
  31. package/dist/published/theme/hooks.d.ts +2 -1
  32. package/package.json +1 -1
@@ -1,4 +1,4 @@
1
- import { useApiServices } from '@evoke-platform/context';
1
+ import { useApiServices, } from '@evoke-platform/context';
2
2
  import { isNil } from 'lodash';
3
3
  import prettyBytes from 'pretty-bytes';
4
4
  import React, { useCallback, useEffect, useState } from 'react';
@@ -7,12 +7,12 @@ import { InfoRounded, UploadCloud } from '../../../../../../icons';
7
7
  import { useFormContext } from '../../../../../../theme/hooks';
8
8
  import { Skeleton, Snackbar, Typography } from '../../../../../core';
9
9
  import { Box, Grid } from '../../../../../layout';
10
- import { getPrefixedUrl } from '../../utils';
10
+ import { getEntryId, getPrefixedUrl, getUnnestedEntries, uploadFiles } from '../../utils';
11
11
  import { DocumentList } from './DocumentList';
12
12
  export const Document = (props) => {
13
- const { id, canUpdateProperty, error, value, validate, hasDescription } = props;
13
+ const { id, fieldType = 'document', canUpdateProperty, error, value, validate, hasDescription } = props;
14
14
  const apiServices = useApiServices();
15
- const { fetchedOptions, setFetchedOptions, object, handleChange, onAutosave: onAutosave, instance, } = useFormContext();
15
+ const { fetchedOptions, setFetchedOptions, object, handleChange, onAutosave: onAutosave, instance, form, } = useFormContext();
16
16
  const [snackbarError, setSnackbarError] = useState();
17
17
  const [documents, setDocuments] = useState();
18
18
  const [hasUpdatePermission, setHasUpdatePermission] = useState(fetchedOptions[`${id}UpdatePermission`]);
@@ -35,8 +35,17 @@ export const Document = (props) => {
35
35
  }, [value]);
36
36
  const checkPermissions = useCallback(() => {
37
37
  if (canUpdateProperty && !fetchedOptions[`${id}UpdatePermission`] && instance?.id) {
38
+ // Find the entry to get the configured createActionId
39
+ const allEntries = getUnnestedEntries(form?.entries ?? []) ?? [];
40
+ const entry = allEntries?.find((entry) => getEntryId(entry) === id);
41
+ const createActionId = entry?.display?.createActionId ?? '_create';
42
+ // For 'file' type properties, check regular object instance permissions
43
+ // For 'document' type properties, check document attachment permissions
44
+ const endpoint = fieldType === 'file'
45
+ ? getPrefixedUrl(`/objects/sys__file/instances/checkAccess?action=execute&field=${createActionId}`)
46
+ : getPrefixedUrl(`/objects/${object?.id}/instances/${instance.id}/documents/checkAccess?action=update`);
38
47
  apiServices
39
- .get(getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/checkAccess?action=update`))
48
+ .get(endpoint)
40
49
  .then((accessCheck) => {
41
50
  setFetchedOptions({
42
51
  [`${id}UpdatePermission`]: accessCheck.result,
@@ -50,16 +59,43 @@ export const Document = (props) => {
50
59
  setHasUpdatePermission(false);
51
60
  });
52
61
  }
53
- }, [canUpdateProperty, fetchedOptions, instance, object]);
62
+ }, [
63
+ canUpdateProperty,
64
+ fetchedOptions[`${id}UpdatePermission`],
65
+ instance?.id,
66
+ object,
67
+ id,
68
+ apiServices,
69
+ form,
70
+ fieldType,
71
+ ]);
54
72
  useEffect(() => {
55
73
  checkPermissions();
56
74
  }, [checkPermissions]);
57
75
  const handleUpload = async (files) => {
58
- // Store File objects in form state - they will be uploaded during autosave via formatSubmission()
59
- const newDocuments = [...(documents ?? []), ...(files ?? [])];
76
+ if (!files?.length) {
77
+ return;
78
+ }
79
+ let uploadedFiles = files;
80
+ // Get the createActionId from display options, default to '_create'
81
+ const allEntries = getUnnestedEntries(form?.entries ?? []);
82
+ const entry = allEntries?.find((entry) => getEntryId(entry) === id);
83
+ const createActionId = entry?.display?.createActionId ?? '_create';
84
+ // Immediately upload files for 'file' type properties when autosave is not enabled.
85
+ // Linking will happen upon final submission.
86
+ // If autosave is enabled, upload and linking will happen in the autosave handler.
87
+ if (fieldType === 'file' && !onAutosave) {
88
+ const { successfulUploads, errorMessage } = await uploadFiles(files, apiServices, createActionId, undefined, false);
89
+ uploadedFiles = successfulUploads;
90
+ if (errorMessage) {
91
+ setSnackbarError({ message: errorMessage, type: 'error' });
92
+ }
93
+ }
94
+ // Store uploaded file references (or File objects) in form state
95
+ const newDocuments = [...(documents ?? []), ...uploadedFiles];
60
96
  setDocuments(newDocuments);
61
97
  try {
62
- handleChange && (await handleChange(id, newDocuments));
98
+ await handleChange?.(id, newDocuments);
63
99
  }
64
100
  catch (error) {
65
101
  console.error('Failed to update field:', error);
@@ -127,7 +163,7 @@ export const Document = (props) => {
127
163
  } }, validate?.maxDocuments === 1
128
164
  ? `Maximum size is ${formattedMaxSize}.`
129
165
  : `The maximum size of each document is ${formattedMaxSize}.`)))))),
130
- 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 })),
166
+ canUpdateProperty && isNil(hasUpdatePermission) ? (React.createElement(Skeleton, { variant: "rectangular", height: formattedMaxSize || allowedTypesMessage ? '136px' : '115px', sx: { margin: '5px 0', borderRadius: '8px' } })) : (React.createElement(DocumentList, { id: id, fieldType: fieldType, handleChange: handleChange, onAutosave: onAutosave, value: documents, setSnackbarError: (type, message) => setSnackbarError({ message, type }), canUpdateProperty: canUpdateProperty && !!hasUpdatePermission })),
131
167
  React.createElement(Snackbar, { open: !!snackbarError?.message, handleClose: () => setSnackbarError(null), message: snackbarError?.message, error: snackbarError?.type === 'error' }),
132
168
  errors.length > 0 && (React.createElement(Box, { display: 'flex', alignItems: 'center' },
133
169
  React.createElement(InfoRounded, { sx: { fontSize: '.75rem', marginRight: '3px', color: '#D3271B' } }),
@@ -1,11 +1,12 @@
1
1
  import React from 'react';
2
- import { SavedDocumentReference } from '../../types';
2
+ import { DocumentReference } from '../../types';
3
3
  type DocumentListProps = {
4
- handleChange?: (propertyId: string, value: (File | SavedDocumentReference)[] | undefined) => void;
4
+ handleChange?: (propertyId: string, value: (File | DocumentReference)[] | undefined) => void;
5
5
  onAutosave?: (fieldId: string) => void | Promise<void>;
6
6
  id: string;
7
+ fieldType?: 'document' | 'file';
7
8
  canUpdateProperty: boolean;
8
- value: (File | SavedDocumentReference)[] | undefined;
9
+ value: (File | DocumentReference)[] | undefined;
9
10
  setSnackbarError: (type: 'error' | 'success', message: string) => void;
10
11
  };
11
12
  export declare const DocumentList: (props: DocumentListProps) => React.JSX.Element;
@@ -1,5 +1,4 @@
1
1
  import { useApiServices } from '@evoke-platform/context';
2
- import { isEqual } from 'lodash';
3
2
  import prettyBytes from 'pretty-bytes';
4
3
  import React, { useEffect, useState } from 'react';
5
4
  import { FileWithExtension, LaunchRounded, TrashCan, WarningRounded } from '../../../../../../icons';
@@ -24,29 +23,33 @@ const viewableFileTypes = [
24
23
  'text/plain',
25
24
  ];
26
25
  export const DocumentList = (props) => {
27
- const { handleChange, onAutosave, id, canUpdateProperty, value: documents, setSnackbarError } = props;
26
+ const { handleChange, onAutosave, id, fieldType = 'document', canUpdateProperty, value: documents, setSnackbarError, } = props;
28
27
  const apiServices = useApiServices();
29
28
  const { fetchedOptions, setFetchedOptions, object, instance } = useFormContext();
30
29
  // Determine property type once at component level
31
- const propertyType = object?.properties?.find((p) => p.id === id)?.type;
32
- const isFileType = propertyType === 'file';
30
+ const isFileType = fieldType === 'file';
33
31
  const [hasViewPermission, setHasViewPermission] = useState(fetchedOptions[`${id}ViewPermission`] ?? true);
34
32
  // savedDocuments is either FileInstance[] or DocumentType[], never a mix
35
33
  const [savedDocuments, setSavedDocuments] = useState(fetchedOptions[`${id}SavedDocuments`]);
36
34
  useEffect(() => {
37
- const currentValue = instance?.[id];
35
+ // Use documents prop (value) as the source of truth, not instance[id]
36
+ // This ensures newly uploaded files trigger a fetch even before they're saved to instance
37
+ const currentValue = documents;
38
38
  if (currentValue?.length) {
39
- const currentDocumentIds = currentValue.map((doc) => doc.id);
40
- if (currentDocumentIds.length &&
41
- // these need to be sorted otherwise it will evaluate as not equal if the ids are in different orders causing unnecessary fetches
42
- !isEqual(currentDocumentIds.slice().sort(), savedDocuments
43
- ?.map((doc) => doc.id)
44
- .slice()
45
- .sort())) {
46
- getDocuments(currentDocumentIds);
39
+ // Filter out File objects only - we want to fetch details for all DocumentReferences including unsaved ones
40
+ const currentDocumentIds = currentValue
41
+ .filter((doc) => !(doc instanceof File))
42
+ .map((doc) => doc.id);
43
+ if (currentDocumentIds.length) {
44
+ // Check if there are any document IDs that we haven't fetched yet
45
+ const savedDocumentIds = savedDocuments?.map((doc) => doc.id) ?? [];
46
+ const missingDocumentIds = currentDocumentIds.filter((id) => !savedDocumentIds.includes(id));
47
+ if (missingDocumentIds.length > 0) {
48
+ getDocuments(currentDocumentIds);
49
+ }
47
50
  }
48
51
  }
49
- }, [id, documents, object]);
52
+ }, [documents, savedDocuments]);
50
53
  useEffect(() => {
51
54
  if (fetchedOptions[`${id}SavedDocuments`]) {
52
55
  setSavedDocuments(fetchedOptions[`${id}SavedDocuments`]);
@@ -83,26 +86,31 @@ export const DocumentList = (props) => {
83
86
  if (!fetchedOptions[`${id}ViewPermission`]) {
84
87
  checkPermissions();
85
88
  }
86
- }, [object]);
89
+ }, [object, id, fetchedOptions[`${id}ViewPermission`]]);
87
90
  const checkPermissions = () => {
88
91
  if (instance?.[id]?.length) {
92
+ const endpoint = isFileType
93
+ ? getPrefixedUrl(`/objects/sys__file/instances/checkAccess?action=read&field=content`)
94
+ : getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/checkAccess?action=view`);
89
95
  apiServices
90
- .get(getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/checkAccess?action=view`))
96
+ .get(endpoint)
91
97
  .then((viewPermissionCheck) => {
92
98
  setFetchedOptions({
93
99
  [`${id}ViewPermission`]: viewPermissionCheck.result,
94
100
  });
95
101
  setHasViewPermission(viewPermissionCheck.result);
96
- });
102
+ })
103
+ .catch(() => setHasViewPermission(false));
97
104
  }
98
105
  };
99
106
  const isFile = (doc) => doc instanceof File;
107
+ const isUnsavedFile = (doc) => isFile(doc) || !!doc.unsaved;
100
108
  const fileExists = (doc) => savedDocuments?.find((d) => d.id === doc.id);
101
109
  const handleRemove = async (index) => {
102
110
  const updatedDocuments = documents?.filter((_, i) => i !== index) ?? [];
103
111
  const newValue = updatedDocuments.length === 0 ? undefined : updatedDocuments;
104
112
  try {
105
- handleChange && (await handleChange(id, newValue));
113
+ handleChange?.(id, newValue);
106
114
  }
107
115
  catch (error) {
108
116
  console.error('Failed to update field:', error);
@@ -124,9 +132,7 @@ export const DocumentList = (props) => {
124
132
  : savedDocuments?.find((savedDocument) => savedDocument.id === doc.id)?.contentType;
125
133
  if (!isFile(doc)) {
126
134
  try {
127
- // Determine property type to use the correct endpoint
128
- const propertyType = object?.properties?.find((p) => p.id === id)?.type;
129
- const contentEndpoint = propertyType === 'file'
135
+ const contentEndpoint = isFileType
130
136
  ? getPrefixedUrl(`/files/${doc.id}/content`)
131
137
  : getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/${doc.id}/content`);
132
138
  const documentResponse = await apiServices.get(contentEndpoint, { responseType: 'blob' });
@@ -189,10 +195,10 @@ export const DocumentList = (props) => {
189
195
  } }, doc.name)),
190
196
  React.createElement(Grid, { item: true, xs: 12 },
191
197
  React.createElement(Typography, { sx: { fontSize: '12px', color: '#637381' } }, getDocumentSize(doc)))),
192
- (isFile(doc) || (hasViewPermission && !isFile(doc) && fileExists(doc))) && (React.createElement(Grid, { item: true },
198
+ (isUnsavedFile(doc) || (hasViewPermission && !isFile(doc) && fileExists(doc))) && (React.createElement(Grid, { item: true },
193
199
  React.createElement(IconButton, { "aria-label": "open document", sx: { ...styles.icon, marginRight: '16px' }, onClick: () => openDocument(index) },
194
200
  React.createElement(LaunchRounded, { sx: { color: '#637381', fontSize: '22px' } })))),
195
- !isFile(doc) && savedDocuments && !fileExists(doc) && (React.createElement(Chip, { label: "Deleted", sx: {
201
+ !isFile(doc) && !isUnsavedFile(doc) && savedDocuments && !fileExists(doc) && (React.createElement(Chip, { label: "Deleted", sx: {
196
202
  marginRight: '16px',
197
203
  backgroundColor: 'rgba(222, 48, 36, 0.16)',
198
204
  color: '#A91813',
@@ -87,7 +87,9 @@ export function RecursiveEntryRenderer(props) {
87
87
  return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) },
88
88
  React.createElement(ObjectPropertyInput, { relatedObjectId: !fieldDefinition.objectId ? display?.relatedObjectId : fieldDefinition.objectId, fieldDefinition: fieldDefinition, id: entryId, mode: display?.mode || 'default', error: !!errors?.[entryId], displayOption: display?.relatedObjectDisplay || 'dialogBox', initialValue: fieldValue, readOnly: entry.type === 'readonlyField', filter: 'criteria' in validation && validation.criteria
89
89
  ? updateCriteriaInputs(validation.criteria, getValues(), userAccount, instance)
90
- : undefined, sortBy: typeof display?.defaultValue === 'object' && 'sortBy' in display.defaultValue
90
+ : entry.display?.criteria
91
+ ? updateCriteriaInputs(entry.display?.criteria, getValues(), userAccount, instance)
92
+ : undefined, sortBy: typeof display?.defaultValue === 'object' && 'sortBy' in display.defaultValue
91
93
  ? display?.defaultValue.sortBy
92
94
  : undefined, orderBy: typeof display?.defaultValue === 'object' && 'orderBy' in display.defaultValue
93
95
  ? display?.defaultValue.orderBy
@@ -136,7 +138,7 @@ export function RecursiveEntryRenderer(props) {
136
138
  }
137
139
  else if (fieldDefinition.type === 'document' || fieldDefinition.type === 'file') {
138
140
  return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) },
139
- React.createElement(Document, { id: entryId, error: !!errors?.[entryId], value: fieldValue, canUpdateProperty: !(entry.type === 'readonlyField'), hasDescription: !!display?.description, validate: validation })));
141
+ React.createElement(Document, { id: entryId, fieldType: fieldDefinition.type, error: !!errors?.[entryId], value: fieldValue, canUpdateProperty: !(entry.type === 'readonlyField'), hasDescription: !!display?.description, validate: validation })));
140
142
  }
141
143
  else if (fieldDefinition.type === 'criteria') {
142
144
  return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) },
@@ -12,9 +12,10 @@ export type FieldAddress = {
12
12
  export type AccessCheck = {
13
13
  result: boolean;
14
14
  };
15
- export type SavedDocumentReference = {
15
+ export type DocumentReference = {
16
16
  id: string;
17
17
  name: string;
18
+ unsaved?: boolean;
18
19
  };
19
20
  export type Document = {
20
21
  id: string;
@@ -112,3 +113,7 @@ export type InstanceLink = {
112
113
  id: string;
113
114
  objectId: string;
114
115
  };
116
+ export type FileUploadBatchResult = {
117
+ errorMessage?: string;
118
+ successfulUploads: DocumentReference[];
119
+ };
@@ -4,7 +4,7 @@ import { LocalDateTime } from '@js-joda/core';
4
4
  import { FieldErrors, FieldValues } from 'react-hook-form';
5
5
  import { ObjectProperty } from '../../../../types';
6
6
  import { AutocompleteOption } from '../../../core';
7
- import { InstanceLink, SavedDocumentReference } from './types';
7
+ import { DocumentReference, FileUploadBatchResult, InstanceLink } from './types';
8
8
  export declare const scrollIntoViewWithOffset: (el: HTMLElement, offset: number, container?: HTMLElement) => void;
9
9
  export declare const normalizeDateTime: (dateTime: LocalDateTime) => string;
10
10
  export declare function isAddressProperty(key: string): boolean;
@@ -49,16 +49,13 @@ export declare const convertPropertiesToParams: (object: Obj) => InputParameter[
49
49
  export declare function getUnnestedEntries(entries: FormEntry[]): FormEntry[];
50
50
  export declare const isEmptyWithDefault: (fieldValue: unknown, entry: InputParameterReference | InputField, instance: Record<string, unknown> | object) => boolean | "" | 0 | undefined;
51
51
  export declare const docProperties: Property[];
52
- /**
53
- * Upload files using the POST /files endpoint for sys__file objects
54
- */
55
- export declare const uploadFiles: (files: (File | SavedDocumentReference)[], apiServices: ApiServices, actionId?: string, metadata?: Record<string, string>, linkTo?: InstanceLink) => Promise<SavedDocumentReference[]>;
52
+ export declare const uploadFiles: (files: (File | DocumentReference)[], apiServices: ApiServices, actionId?: string, linkTo?: InstanceLink, shortCircuit?: boolean) => Promise<FileUploadBatchResult>;
56
53
  /**
57
54
  * Creates file links for uploaded files by calling the objects endpoint with sys__fileLink
58
55
  * This is used after instance creation when the instance ID becomes available
59
56
  */
60
- export declare const createFileLinks: (files: SavedDocumentReference[], linkedInstance: InstanceLink, apiServices: ApiServices) => Promise<void>;
61
- export declare const uploadDocuments: (files: (File | SavedDocumentReference)[], metadata: Record<string, string>, apiServices: ApiServices, instanceId: string, objectId: string) => Promise<SavedDocumentReference[]>;
57
+ export declare const createFileLinks: (fileReferences: DocumentReference[], linkedInstance: InstanceLink, apiServices: ApiServices) => Promise<void>;
58
+ export declare const uploadDocuments: (files: (File | DocumentReference)[], metadata: Record<string, string>, apiServices: ApiServices, instanceId: string, objectId: string) => Promise<DocumentReference[]>;
62
59
  export declare const deleteDocuments: (submittedFields: FieldValues, requestSuccess: boolean, apiServices: ApiServices, object: Obj, instance: FieldValues, action?: Action, setSnackbarError?: React.Dispatch<React.SetStateAction<{
63
60
  showAlert: boolean;
64
61
  message?: string;
@@ -84,6 +81,7 @@ export declare function formatSubmission(submission: FieldValues, apiServices?:
84
81
  }>>, associatedObject?: {
85
82
  instanceId: string;
86
83
  propertyId: string;
84
+ objectId?: string;
87
85
  }, parameters?: InputParameter[]): Promise<FieldValues>;
88
86
  export declare function filterEmptySections(entry: Sections | Columns, instance?: FieldValues, formData?: FieldValues): Sections | Columns | null;
89
87
  export declare function assignIdsToSectionsAndRichText(entries: FormEntry[], object: Obj, parameters?: InputParameter[]): FormEntry[];
@@ -127,7 +127,15 @@ const getEntryType = (entry, parameters) => {
127
127
  };
128
128
  export function getPrefixedUrl(url) {
129
129
  const wcsMatchers = ['/apps', '/pages', '/widgets'];
130
- const dataMatchers = ['/objects', '/correspondenceTemplates', '/documents', '/payments', '/forms', '/locations'];
130
+ const dataMatchers = [
131
+ '/objects',
132
+ '/correspondenceTemplates',
133
+ '/documents',
134
+ '/payments',
135
+ '/forms',
136
+ '/locations',
137
+ '/files',
138
+ ];
131
139
  const signalrMatchers = ['/hubs'];
132
140
  const accessManagementMatchers = ['/users'];
133
141
  const workflowMatchers = ['/workflows'];
@@ -479,53 +487,90 @@ export const docProperties = [
479
487
  type: 'string',
480
488
  },
481
489
  ];
482
- /**
483
- * Upload files using the POST /files endpoint for sys__file objects
484
- */
485
- export const uploadFiles = async (files, apiServices, actionId = '_create', metadata, linkTo) => {
490
+ export const uploadFiles = async (files, apiServices, actionId = '_create', linkTo, shortCircuit = true) => {
486
491
  // Separate already uploaded files from files that need uploading
487
492
  const alreadyUploaded = files.filter((file) => !('size' in file));
488
493
  const filesToUpload = files.filter((file) => 'size' in file);
489
- // Upload all files in parallel
490
- const uploadPromises = filesToUpload.map(async (file) => {
494
+ let failedUpload = false;
495
+ // Upload all files in parallel, handling each result individually
496
+ const uploadPromises = [];
497
+ for (const file of filesToUpload) {
491
498
  const formData = new FormData();
492
499
  formData.append('file', file);
493
500
  formData.append('actionId', actionId);
494
501
  formData.append('objectId', 'sys__file');
495
- if (metadata) {
496
- for (const [key, value] of Object.entries(metadata)) {
497
- formData.append(key, value);
498
- }
499
- }
502
+ formData.append('input', JSON.stringify({ name: file.name, contentType: file.type }));
500
503
  if (linkTo) {
501
504
  formData.append('linkTo', JSON.stringify(linkTo));
502
505
  }
503
- const fileInstance = await apiServices.post(getPrefixedUrl(`/files`), formData);
506
+ const uploadPromise = (async () => {
507
+ try {
508
+ const fileInstance = await apiServices.post(getPrefixedUrl(`/files`), formData);
509
+ return {
510
+ id: fileInstance.id,
511
+ name: fileInstance.name,
512
+ unsaved: true,
513
+ };
514
+ }
515
+ catch (error) {
516
+ console.error(`Failed to upload file ${file.name}:`, error);
517
+ failedUpload = true;
518
+ }
519
+ })();
520
+ uploadPromises.push(uploadPromise);
521
+ }
522
+ if (!shortCircuit) {
523
+ // Wait for all upload attempts to complete (successes and failures)
524
+ const uploadResults = await Promise.allSettled(uploadPromises);
525
+ const uploadedFiles = uploadResults
526
+ .filter((result) => result.status === 'fulfilled' && !!result.value)
527
+ .map((result) => result.value);
528
+ const failedCount = filesToUpload.length - uploadedFiles.length;
504
529
  return {
505
- id: fileInstance.id,
506
- name: fileInstance.name,
530
+ successfulUploads: [...alreadyUploaded, ...uploadedFiles],
531
+ errorMessage: failedCount > 0 ? `Failed to upload ${failedCount} file(s)` : undefined,
507
532
  };
508
- });
533
+ }
509
534
  const uploadedFiles = await Promise.all(uploadPromises);
510
- return [...alreadyUploaded, ...uploadedFiles];
535
+ if (failedUpload) {
536
+ return {
537
+ successfulUploads: [],
538
+ errorMessage: 'An error occurred when uploading files',
539
+ };
540
+ }
541
+ return {
542
+ successfulUploads: [...alreadyUploaded, ...uploadedFiles],
543
+ };
511
544
  };
512
545
  /**
513
546
  * Creates file links for uploaded files by calling the objects endpoint with sys__fileLink
514
547
  * This is used after instance creation when the instance ID becomes available
515
548
  */
516
- export const createFileLinks = async (files, linkedInstance, apiServices) => {
517
- const linkPromises = files.map(async (file) => {
518
- await apiServices.post(getPrefixedUrl(`/objects/sys__fileLink/instances`), {
519
- name: 'File Link',
520
- file: { id: file.id, name: file.name },
521
- linkedInstance,
522
- }, {
523
- params: {
524
- actionId: '_create',
525
- },
526
- });
527
- });
528
- await Promise.all(linkPromises);
549
+ export const createFileLinks = async (fileReferences, linkedInstance, apiServices) => {
550
+ // Link files in parallel, handling each result individually
551
+ const linkPromises = [];
552
+ for (const file of fileReferences) {
553
+ const linkPromise = (async () => {
554
+ try {
555
+ await apiServices.post(getPrefixedUrl(`/objects/sys__fileLink/instances`), {
556
+ name: 'File Link',
557
+ file: { id: file.id, name: file.name },
558
+ linkedInstance,
559
+ }, {
560
+ params: {
561
+ actionId: '_create',
562
+ },
563
+ });
564
+ }
565
+ catch (error) {
566
+ console.error(`Failed to create file link for ${file.name}:`, error);
567
+ // The file remains unlinked and can be retried later
568
+ }
569
+ })();
570
+ linkPromises.push(linkPromise);
571
+ }
572
+ // Wait for all linking attempts to complete (successes and failures)
573
+ await Promise.allSettled(linkPromises);
529
574
  };
530
575
  export const uploadDocuments = async (files, metadata, apiServices, instanceId, objectId) => {
531
576
  const allDocuments = [];
@@ -553,20 +598,29 @@ export const uploadDocuments = async (files, metadata, apiServices, instanceId,
553
598
  export const deleteDocuments = async (submittedFields, requestSuccess, apiServices, object, instance, action, setSnackbarError) => {
554
599
  const documentProperties = action?.parameters
555
600
  ? action.parameters.filter((param) => ['document', 'file'].includes(param.type))
556
- : object?.properties?.filter((prop) => ['document', 'file'].includes(prop.type));
601
+ : object.properties?.filter((prop) => ['document', 'file'].includes(prop.type));
557
602
  for (const docProperty of documentProperties ?? []) {
558
603
  const savedValue = submittedFields[docProperty.id];
559
- const originalValue = instance?.[docProperty.id];
604
+ const originalValue = instance[docProperty.id];
560
605
  const documentsToRemove = requestSuccess
561
606
  ? (originalValue?.filter((file) => !savedValue?.some((f) => f.id === file.id)) ?? [])
562
607
  : (savedValue?.filter((file) => !originalValue?.some((f) => f.id === file.id)) ?? []);
563
608
  for (const doc of documentsToRemove) {
564
609
  try {
565
- // Use different endpoints based on property type
566
- const deleteEndpoint = docProperty.type === 'file'
567
- ? getPrefixedUrl(`/files/${doc.id}`)
568
- : getPrefixedUrl(`/objects/${object.id}/instances/${instance.id}/documents/${doc.id}`);
569
- await apiServices?.delete(deleteEndpoint);
610
+ // Build context for state model
611
+ const fieldType = docProperty.type === 'file' ? 'file' : 'document';
612
+ if (fieldType === 'file') {
613
+ // For file properties, unlink the file. Don't delete the actual file
614
+ // since other instances may be using it.
615
+ await apiServices.post(getPrefixedUrl(`/files/${doc.id}/unlinkInstance`), {
616
+ linkedInstanceId: instance.id,
617
+ linkedObjectId: object.id,
618
+ });
619
+ }
620
+ else {
621
+ // For document properties, delete the document
622
+ await apiServices.delete(getPrefixedUrl(`/objects/${object.id}/instances/${instance.id}/documents/${doc.id}`));
623
+ }
570
624
  }
571
625
  catch (error) {
572
626
  if (error) {
@@ -581,6 +635,28 @@ export const deleteDocuments = async (submittedFields, requestSuccess, apiServic
581
635
  }
582
636
  }
583
637
  };
638
+ async function handleUploads(files, propertyType, entry, apiServices, objectId, instanceId) {
639
+ if (propertyType === 'file') {
640
+ // Get the createActionId from display options, default to '_create'
641
+ const createActionId = entry?.display?.createActionId ?? '_create';
642
+ return await uploadFiles(files, apiServices, createActionId, instanceId ? { id: instanceId, objectId } : undefined);
643
+ }
644
+ else if (propertyType === 'document' && instanceId) {
645
+ try {
646
+ const docs = await uploadDocuments(files, {
647
+ type: '',
648
+ view_permission: '',
649
+ ...entry?.documentMetadata,
650
+ }, apiServices, instanceId, objectId);
651
+ return { successfulUploads: docs };
652
+ }
653
+ catch (error) {
654
+ console.error('Error uploading documents:', error);
655
+ return { successfulUploads: [], errorMessage: 'Error uploading documents' };
656
+ }
657
+ }
658
+ return { successfulUploads: [] };
659
+ }
584
660
  /**
585
661
  * Transforms a form submission into a format safe for API submission.
586
662
  *
@@ -598,44 +674,44 @@ export async function formatSubmission(submission, apiServices, objectId, instan
598
674
  if (associatedObject) {
599
675
  delete submission[associatedObject.propertyId];
600
676
  }
601
- const allEntries = getUnnestedEntries(form?.entries ?? []) ?? [];
677
+ const allEntries = getUnnestedEntries(form?.entries ?? []);
602
678
  for (const [key, value] of Object.entries(submission)) {
603
679
  const entry = allEntries?.find((entry) => getEntryId(entry) === key);
604
680
  if (isArray(value)) {
605
- // Only upload if array contains File instances (not SavedDocumentReference)
681
+ const propertyType = getEntryType(entry, parameters);
682
+ // The only array types we need to handle specially are 'file' and 'document'.
683
+ if (propertyType !== 'file' && propertyType !== 'document') {
684
+ continue;
685
+ }
686
+ // Only upload if array contains File instances (not SavedDocumentReference).
606
687
  const fileInArray = value.some((item) => item instanceof File);
607
688
  if (fileInArray && apiServices && objectId) {
608
- // Determine property type from the entry
609
- const propertyType = getEntryType(entry, parameters);
610
- try {
611
- let uploadedDocuments = [];
612
- if (propertyType === 'file') {
613
- uploadedDocuments = await uploadFiles(value, apiServices, '_create', {
614
- ...entry?.documentMetadata,
615
- },
616
- // Only pass linkTo if instanceId exists (update action)
617
- instanceId ? { id: instanceId, objectId } : undefined);
618
- }
619
- else if (propertyType === 'document' && instanceId) {
620
- uploadedDocuments = await uploadDocuments(value, {
621
- type: '',
622
- view_permission: '',
623
- ...entry?.documentMetadata,
624
- }, apiServices, instanceId, objectId);
625
- }
626
- submission[key] = uploadedDocuments;
627
- }
628
- catch (err) {
629
- if (err) {
630
- setSnackbarError &&
631
- setSnackbarError({
632
- showAlert: true,
633
- message: `An error occurred while uploading associated ${propertyType === 'file' ? 'files' : 'documents'}`,
634
- isError: true,
635
- });
636
- }
689
+ const result = await handleUploads(value, propertyType ?? '', entry, apiServices, objectId, instanceId);
690
+ if (result.errorMessage) {
691
+ setSnackbarError?.({
692
+ showAlert: true,
693
+ // Provide generic message since we're ignoring a partial upload.
694
+ message: `An error occurred while uploading associated ${propertyType === 'file' ? 'files' : 'documents'}`,
695
+ isError: true,
696
+ });
637
697
  return submission;
638
698
  }
699
+ else {
700
+ // Filter out 'unsaved' flag before submission since the api doesn't know about it.
701
+ submission[key] = result.successfulUploads.map((file) => ({ id: file.id, name: file.name }));
702
+ }
703
+ }
704
+ else {
705
+ // Filter out 'unsaved' flag before submission since the api doesn't know about it.
706
+ submission[key] = value
707
+ // This should never happen but it's possible that the else branch
708
+ // is reached because either 'apiServices' or 'objectId' is undefined.
709
+ // If that's the case the submission will fail if we submit File blobs.
710
+ .filter((file) => !(file instanceof File))
711
+ .map((file) => ({
712
+ id: file.id,
713
+ name: file.name,
714
+ }));
639
715
  }
640
716
  // if there are address fields with no value address needs to be set to undefined to be able to submit
641
717
  }
@@ -649,7 +725,8 @@ export async function formatSubmission(submission, apiServices, objectId, instan
649
725
  submission[key] =
650
726
  entry &&
651
727
  ['input', 'inputField'].includes(entry.type) &&
652
- entry.display?.relatedObjectId
728
+ (entry.display?.relatedObjectId ||
729
+ associatedObject?.objectId)
653
730
  ? pick(value, 'id', 'name', 'objectId')
654
731
  : pick(value, 'id', 'name');
655
732
  }