@evoke-platform/ui-components 1.13.0-dev.5 → 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.
@@ -43,7 +43,7 @@ export const Document = (props) => {
43
43
  // For 'file' type properties, check permissions on the sys__file object
44
44
  // For 'document' type properties, check document attachment permissions
45
45
  const endpoint = property.type === 'file'
46
- ? getPrefixedUrl(`/objects/sys__file/instances/${instance.id}/checkAccess?action=update`)
46
+ ? getPrefixedUrl('/objects/sys__file/instances/checkAccess?action=execute&field=_create')
47
47
  : getPrefixedUrl(`/objects/${objectId}/instances/${instance.id}/documents/checkAccess?action=update`);
48
48
  apiServices
49
49
  .get(endpoint)
@@ -64,12 +64,15 @@ export const DocumentList = (props) => {
64
64
  }, []);
65
65
  const checkPermissions = () => {
66
66
  if (instance?.[property.id]?.length) {
67
- // For 'file' type properties, check permissions on the sys__file object
67
+ // For 'file' type properties, check regular object instance permissions
68
68
  // For 'document' type properties, check document attachment permissions
69
69
  const endpoint = property.type === 'file'
70
- ? getPrefixedUrl(`/objects/sys__file/instances/${instance.id}/checkAccess?action=view`)
70
+ ? getPrefixedUrl(`/objects/sys__file/instances/checkAccess?action=read&field=content`)
71
71
  : getPrefixedUrl(`/objects/${objectId}/instances/${instance.id}/documents/checkAccess?action=view`);
72
- apiServices.get(endpoint).then((accessCheck) => setHasViewPermission(accessCheck.result));
72
+ apiServices
73
+ .get(endpoint)
74
+ .then((accessCheck) => setHasViewPermission(accessCheck.result))
75
+ .catch(() => setHasViewPermission(false));
73
76
  }
74
77
  };
75
78
  const isFile = (doc) => doc instanceof File;
@@ -158,7 +158,7 @@ function FormRendererContainer(props) {
158
158
  });
159
159
  if (navigationSlug) {
160
160
  if (navigationSlug.includes(':instanceId')) {
161
- const navigateInstanceId = action?.type === 'create' ? updatedInstance?.id : instanceId;
161
+ const navigateInstanceId = action?.type === 'create' ? updatedInstance.id : instanceId;
162
162
  navigateTo(`/${appId}/${navigationSlug.replace(':instanceId', navigateInstanceId ?? ':instanceId')}`);
163
163
  }
164
164
  else {
@@ -167,18 +167,17 @@ function FormRendererContainer(props) {
167
167
  }
168
168
  setInstance(updatedInstance);
169
169
  };
170
+ /**
171
+ * Manually links any newly uploaded files in the submission to the specified instance.
172
+ * @param submission The form submission data
173
+ * @param linkTo The instance to link the files to
174
+ */
170
175
  const linkFiles = async (submission, linkTo) => {
171
- // Create file links for any uploaded files after instance creation
176
+ // Create file links for any uploaded files that haven't been linked yet
172
177
  for (const property of sanitizedObject?.properties?.filter((property) => property.type === 'file') ?? []) {
173
178
  const files = submission[property.id];
174
179
  if (files?.length) {
175
- try {
176
- await createFileLinks(files, linkTo, apiServices);
177
- }
178
- catch (error) {
179
- console.error('Failed to create file links:', error);
180
- // Don't fail the entire submission if file linking fails
181
- }
180
+ await createFileLinks(files, linkTo, apiServices);
182
181
  }
183
182
  }
184
183
  };
@@ -195,11 +194,9 @@ function FormRendererContainer(props) {
195
194
  ?.filter((property) => property.formula || property.type === 'collection')
196
195
  .map((property) => property.id) ?? []),
197
196
  });
198
- if (response) {
199
- // Manually link files to created instance.
200
- await linkFiles(submission, { id: response.id, objectId: form.objectId });
201
- onSubmissionSuccess(response);
202
- }
197
+ // Manually link files to created instance.
198
+ await linkFiles(submission, { id: response.id, objectId: form.objectId });
199
+ onSubmissionSuccess(response);
203
200
  }
204
201
  else if (instanceId && action) {
205
202
  const response = await objectStore.instanceAction(instanceId, {
@@ -209,21 +206,26 @@ function FormRendererContainer(props) {
209
206
  .map((property) => property.id) ?? []),
210
207
  });
211
208
  if (sanitizedObject && instance) {
209
+ if (!onAutosave) {
210
+ // For non-autosave updates, link any uploaded files to the instance.
211
+ await linkFiles(submission, { id: instanceId, objectId: objectId });
212
+ }
212
213
  onSubmissionSuccess(response);
213
- deleteDocuments(submission, true, apiServices, sanitizedObject, instance, action, setSnackbarError);
214
+ // Only delete the necessary files after submission succeeds to avoid deleting a file prematurely.
215
+ await deleteDocuments(submission, true, apiServices, sanitizedObject, instance, action, setSnackbarError);
214
216
  }
215
217
  }
216
218
  }
217
219
  catch (error) {
218
- // Handle deleteDocuments for uploaded documents if the main submission fails
219
- if (instanceId && action && sanitizedObject && instance) {
220
- deleteDocuments(submission, false, apiServices, sanitizedObject, instance, action, setSnackbarError);
221
- }
222
220
  setSnackbarError({
223
221
  isError: true,
224
222
  showAlert: true,
225
223
  message: error.response?.data?.error?.message ?? 'An error occurred',
226
224
  });
225
+ if (instanceId && action && sanitizedObject && instance) {
226
+ // For an update, uploaded documents have been linked to the instance and need to be deleted.
227
+ await deleteDocuments(submission, false, apiServices, sanitizedObject, instance, action, setSnackbarError);
228
+ }
227
229
  throw error; // Throw error so caller knows submission failed
228
230
  }
229
231
  };
@@ -327,7 +329,7 @@ function FormRendererContainer(props) {
327
329
  try {
328
330
  setIsSaving(true);
329
331
  const cleanedData = removeUneditedProtectedValues(formDataRef.current);
330
- const submission = await formatSubmission(cleanedData, apiServices, objectId, instanceId, form, setSnackbarError);
332
+ const submission = await formatSubmission(cleanedData, apiServices, objectId, instanceId, form, setSnackbarError, undefined, parameters);
331
333
  // Handle object instance autosave
332
334
  if (instanceId && action?.type === 'update') {
333
335
  await apiServices.post(getPrefixedUrl(`/objects/${objectId}/instances/${instanceId}/actions`), {
@@ -336,6 +338,7 @@ function FormRendererContainer(props) {
336
338
  ?.filter((property) => !property.formula && property.type !== 'collection')
337
339
  .map((property) => property.id) ?? []),
338
340
  });
341
+ await linkFiles(submission, { id: instanceId, objectId });
339
342
  }
340
343
  setLastSavedData(cloneDeep(formDataRef.current));
341
344
  setIsSaving(false);
@@ -1,12 +1,13 @@
1
1
  import { DocumentParameterValidation } from '@evoke-platform/context';
2
2
  import React from 'react';
3
- import { SavedDocumentReference } from '../../types';
3
+ import { DocumentReference } from '../../types';
4
4
  type DocumentProps = {
5
5
  id: string;
6
+ fieldType?: 'file' | 'document';
6
7
  canUpdateProperty: boolean;
7
8
  error: boolean;
8
9
  validate?: DocumentParameterValidation;
9
- value: (File | SavedDocumentReference)[] | undefined;
10
+ value: (File | DocumentReference)[] | undefined;
10
11
  hasDescription?: boolean;
11
12
  };
12
13
  export declare const Document: (props: DocumentProps) => React.JSX.Element;
@@ -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',
@@ -138,7 +138,7 @@ export function RecursiveEntryRenderer(props) {
138
138
  }
139
139
  else if (fieldDefinition.type === 'document' || fieldDefinition.type === 'file') {
140
140
  return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) },
141
- 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 })));
142
142
  }
143
143
  else if (fieldDefinition.type === 'criteria') {
144
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;
@@ -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
  }
@@ -143,7 +143,7 @@ function ViewOnlyEntryRenderer(props) {
143
143
  }
144
144
  else if (fieldDefinition.type === 'document' || fieldDefinition.type === 'file') {
145
145
  return (React.createElement(FieldWrapper, { inputId: entryId, inputType: fieldDefinition.type, label: display?.label || fieldDefinition?.name || 'default', value: fieldValue, required: display?.required || false, prefix: display?.prefix, suffix: display?.suffix, viewOnly: true },
146
- React.createElement(Document, { id: entryId, error: false, value: fieldValue, canUpdateProperty: false })));
146
+ React.createElement(Document, { id: entryId, fieldType: fieldDefinition.type, error: false, value: fieldValue, canUpdateProperty: false })));
147
147
  }
148
148
  else if (fieldDefinition.type === 'collection') {
149
149
  return fieldDefinition?.manyToManyPropertyId ? (middleObject && initialMiddleObjectInstances && (React.createElement(FieldWrapper, { inputId: entryId, inputType: 'collection', label: display?.label || fieldDefinition?.name || 'default', value: fieldValue, required: display?.required || false, viewOnly: true },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoke-platform/ui-components",
3
- "version": "1.13.0-dev.5",
3
+ "version": "1.13.0-dev.6",
4
4
  "description": "",
5
5
  "main": "dist/published/index.js",
6
6
  "module": "dist/published/index.js",