@evoke-platform/ui-components 1.13.0-dev.5 → 1.13.0-dev.7

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 (31) hide show
  1. package/dist/published/components/custom/Form/FormComponents/DocumentComponent/Document.js +1 -1
  2. package/dist/published/components/custom/Form/FormComponents/DocumentComponent/DocumentList.js +6 -3
  3. package/dist/published/components/custom/FormV2/FormRenderer.d.ts +1 -1
  4. package/dist/published/components/custom/FormV2/FormRenderer.js +25 -27
  5. package/dist/published/components/custom/FormV2/FormRendererContainer.js +93 -86
  6. package/dist/published/components/custom/FormV2/components/ConditionalQueryClientProvider.d.ts +5 -0
  7. package/dist/published/components/custom/FormV2/components/ConditionalQueryClientProvider.js +21 -0
  8. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/DropdownRepeatableField.js +86 -143
  9. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/DropdownRepeatableFieldInput.d.ts +0 -2
  10. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/DropdownRepeatableFieldInput.js +1 -4
  11. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +104 -184
  12. package/dist/published/components/custom/FormV2/components/FormFieldTypes/Criteria.js +36 -49
  13. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.d.ts +3 -2
  14. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +51 -32
  15. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.d.ts +4 -3
  16. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.js +40 -38
  17. package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +17 -21
  18. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/ObjectPropertyInput.js +95 -169
  19. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/RelatedObjectInstance.d.ts +0 -2
  20. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/RelatedObjectInstance.js +12 -6
  21. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.d.ts +2 -1
  22. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +39 -17
  23. package/dist/published/components/custom/FormV2/components/types.d.ts +6 -1
  24. package/dist/published/components/custom/FormV2/components/utils.d.ts +10 -11
  25. package/dist/published/components/custom/FormV2/components/utils.js +169 -93
  26. package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +48 -15
  27. package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +38 -46
  28. package/dist/published/components/custom/ViewDetailsV2/InstanceEntryRenderer.d.ts +2 -1
  29. package/dist/published/components/custom/ViewDetailsV2/InstanceEntryRenderer.js +38 -13
  30. package/dist/published/components/custom/ViewDetailsV2/ViewDetailsV2Container.js +7 -2
  31. package/package.json +3 -2
@@ -1,10 +1,11 @@
1
1
  import { useApiServices, useAuthenticationContext, } from '@evoke-platform/context';
2
2
  import { WarningRounded } from '@mui/icons-material';
3
+ import { useQuery } from '@tanstack/react-query';
3
4
  import DOMPurify from 'dompurify';
4
5
  import { isEmpty } from 'lodash';
5
- import React, { useEffect, useMemo } from 'react';
6
+ import React, { useMemo } from 'react';
6
7
  import useWidgetSize, { useFormContext } from '../../../../theme/hooks';
7
- import { TextField, Typography } from '../../../core';
8
+ import { Skeleton, TextField, Typography } from '../../../core';
8
9
  import { Box } from '../../../layout';
9
10
  import FormField from '../../FormField';
10
11
  import AccordionSections from './AccordionSections';
@@ -18,7 +19,7 @@ import { Image } from './FormFieldTypes/Image';
18
19
  import ObjectPropertyInput from './FormFieldTypes/relatedObjectFiles/ObjectPropertyInput';
19
20
  import UserProperty from './FormFieldTypes/UserProperty';
20
21
  import FormSections from './FormSections';
21
- import { entryIsVisible, fetchCollectionData, filterEmptySections, getEntryId, getFieldDefinition, isAddressProperty, isOptionEqualToValue, updateCriteriaInputs, } from './utils';
22
+ import { entryIsVisible, fetchInitialMiddleObjectInstances, fetchMiddleObject, filterEmptySections, getEntryId, getFieldDefinition, isAddressProperty, isOptionEqualToValue, updateCriteriaInputs, } from './utils';
22
23
  function getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors, validation) {
23
24
  return {
24
25
  inputId: entryId,
@@ -38,7 +39,7 @@ function getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, displ
38
39
  }
39
40
  export function RecursiveEntryRenderer(props) {
40
41
  const { entry } = props;
41
- const { fetchedOptions, setFetchedOptions, object, getValues, errors, instance, richTextEditor: RichTextEditor, parameters, handleChange, onAutosave, fieldHeight, triggerFieldReset, associatedObject, width, } = useFormContext();
42
+ const { object, getValues, errors, instance, richTextEditor: RichTextEditor, parameters, handleChange, onAutosave, fieldHeight, triggerFieldReset, associatedObject, width, } = useFormContext();
42
43
  const { isBelow, breakpoints } = useWidgetSize({
43
44
  scroll: false,
44
45
  defaultWidth: width,
@@ -50,19 +51,41 @@ export function RecursiveEntryRenderer(props) {
50
51
  const entryId = getEntryId(entry) || 'defaultId';
51
52
  const display = 'display' in entry ? entry.display : undefined;
52
53
  const fieldValue = entry.type === 'readonlyField' ? instance?.[entryId] : getValues ? getValues(entryId) : undefined;
53
- const initialMiddleObjectInstances = fetchedOptions[`${entryId}InitialMiddleObjectInstances`];
54
- const middleObject = fetchedOptions[`${entryId}MiddleObject`];
55
54
  const fieldDefinition = useMemo(() => {
56
55
  if (!object)
57
56
  return undefined;
58
57
  return getFieldDefinition(entry, object, parameters);
59
58
  }, [entry, parameters, object]);
60
59
  const validation = fieldDefinition?.validation || {};
61
- useEffect(() => {
62
- if (fieldDefinition?.type === 'collection' && fieldDefinition?.manyToManyPropertyId && instance) {
63
- fetchCollectionData(apiServices, fieldDefinition, setFetchedOptions, instance.id, fetchedOptions, initialMiddleObjectInstances);
64
- }
65
- }, [fieldDefinition, instance]);
60
+ const { data: middleObject } = useQuery({
61
+ queryKey: [fieldDefinition?.objectId, 'MiddleObject'],
62
+ queryFn: () => fetchMiddleObject(fieldDefinition, apiServices),
63
+ staleTime: Infinity,
64
+ enabled: !!(fieldDefinition?.objectId &&
65
+ fieldDefinition?.type === 'collection' &&
66
+ fieldDefinition?.manyToManyPropertyId),
67
+ meta: {
68
+ errorMessage: 'Failed to fetch middle object: ',
69
+ },
70
+ });
71
+ const { data: initialMiddleObjectInstances = [], isLoading: isLoadingInstances } = useQuery({
72
+ queryKey: [
73
+ fieldDefinition?.objectId,
74
+ instance?.id,
75
+ fieldDefinition?.relatedPropertyId,
76
+ 'InitialMiddleObjectInstances',
77
+ ],
78
+ queryFn: () => fetchInitialMiddleObjectInstances(apiServices, fieldDefinition, instance?.id),
79
+ staleTime: Infinity,
80
+ enabled: !!(fieldDefinition?.objectId &&
81
+ instance?.id &&
82
+ fieldDefinition?.type === 'collection' &&
83
+ fieldDefinition?.manyToManyPropertyId &&
84
+ fieldDefinition?.relatedPropertyId),
85
+ meta: {
86
+ errorMessage: 'Failed to fetch middle object instances: ',
87
+ },
88
+ });
66
89
  if (associatedObject?.propertyId === entryId)
67
90
  return null;
68
91
  // If the entry is hidden, clear its value and any nested values, and skip rendering
@@ -103,12 +126,11 @@ export function RecursiveEntryRenderer(props) {
103
126
  }
104
127
  else if (fieldDefinition.type === 'collection') {
105
128
  if (fieldDefinition?.manyToManyPropertyId) {
106
- if (middleObject && !isEmpty(middleObject)) {
107
- return (initialMiddleObjectInstances && (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) },
108
- React.createElement(DropdownRepeatableField, { initialMiddleObjectInstances: fetchedOptions[`${entryId}MiddleObjectInstances`] ||
109
- initialMiddleObjectInstances, fieldDefinition: fieldDefinition, id: entryId, middleObject: middleObject, readOnly: entry.type === 'readonlyField', criteria: 'criteria' in validation && validation.criteria
129
+ if (!isEmpty(middleObject)) {
130
+ return isLoadingInstances ? (React.createElement(Skeleton, null)) : (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) },
131
+ React.createElement(DropdownRepeatableField, { initialMiddleObjectInstances: initialMiddleObjectInstances, fieldDefinition: fieldDefinition, id: entryId, middleObject: middleObject, readOnly: entry.type === 'readonlyField', criteria: 'criteria' in validation && validation.criteria
110
132
  ? updateCriteriaInputs(validation.criteria, getValues(), userAccount, instance)
111
- : undefined, hasDescription: !!display?.description }))));
133
+ : undefined, hasDescription: !!display?.description })));
112
134
  }
113
135
  else {
114
136
  // when in the builder preview, the middle object won't be fetched so instead show an empty field
@@ -138,7 +160,7 @@ export function RecursiveEntryRenderer(props) {
138
160
  }
139
161
  else if (fieldDefinition.type === 'document' || fieldDefinition.type === 'file') {
140
162
  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 })));
163
+ React.createElement(Document, { id: entryId, fieldType: fieldDefinition.type, error: !!errors?.[entryId], value: fieldValue, canUpdateProperty: !(entry.type === 'readonlyField'), hasDescription: !!display?.description, validate: validation })));
142
164
  }
143
165
  else if (fieldDefinition.type === 'criteria') {
144
166
  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
+ };
@@ -1,10 +1,10 @@
1
1
  /// <reference types="react" />
2
- import { Action, ApiServices, Column, Columns, EvokeForm, FormEntry, InputField, InputParameter, InputParameterReference, Obj, ObjectInstance, Property, Section, Sections, UserAccount } from '@evoke-platform/context';
2
+ import { Action, ApiServices, Column, Columns, EvokeForm, FormEntry, InputField, InputParameter, InputParameterReference, Obj, ObjectInstance, ObjWithRoot, PanelViewEntry, Property, Section, Sections, UserAccount } from '@evoke-platform/context';
3
3
  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;
@@ -41,7 +41,8 @@ export declare function getDefaultPages(parameters: InputParameter[], defaultPag
41
41
  [x: string]: string;
42
42
  }>;
43
43
  export declare function updateCriteriaInputs(criteria: Record<string, unknown>, data: Record<string, unknown>, user?: UserAccount, instance?: Record<string, unknown>): Record<string, unknown>;
44
- export declare function fetchCollectionData(apiServices: ApiServices, fieldDefinition: InputParameter | Property, setFetchedOptions: (newData: FieldValues) => void, instanceId?: string, fetchedOptions?: Record<string, unknown>, initialMiddleObjectInstances?: ObjectInstance[]): Promise<void>;
44
+ export declare const fetchMiddleObject: (fieldDefinition: InputParameter | Property, apiServices: ApiServices) => Promise<ObjWithRoot | undefined>;
45
+ export declare const fetchInitialMiddleObjectInstances: (apiServices: ApiServices, fieldDefinition: InputParameter | Property, instanceId: string) => Promise<ObjectInstance[]>;
45
46
  export declare const getErrorCountForSection: (section: Section | Column, errors?: FieldErrors) => number;
46
47
  export declare const propertyToParameter: (property: Property) => InputParameter;
47
48
  export declare const propertyValidationToParameterValidation: (property: Property) => InputParameter['validation'];
@@ -49,16 +50,13 @@ export declare const convertPropertiesToParams: (object: Obj) => InputParameter[
49
50
  export declare function getUnnestedEntries(entries: FormEntry[]): FormEntry[];
50
51
  export declare const isEmptyWithDefault: (fieldValue: unknown, entry: InputParameterReference | InputField, instance: Record<string, unknown> | object) => boolean | "" | 0 | undefined;
51
52
  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[]>;
53
+ export declare const uploadFiles: (files: (File | DocumentReference)[], apiServices: ApiServices, actionId?: string, linkTo?: InstanceLink, shortCircuit?: boolean) => Promise<FileUploadBatchResult>;
56
54
  /**
57
55
  * Creates file links for uploaded files by calling the objects endpoint with sys__fileLink
58
56
  * This is used after instance creation when the instance ID becomes available
59
57
  */
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[]>;
58
+ export declare const createFileLinks: (fileReferences: DocumentReference[], linkedInstance: InstanceLink, apiServices: ApiServices) => Promise<void>;
59
+ export declare const uploadDocuments: (files: (File | DocumentReference)[], metadata: Record<string, string>, apiServices: ApiServices, instanceId: string, objectId: string) => Promise<DocumentReference[]>;
62
60
  export declare const deleteDocuments: (submittedFields: FieldValues, requestSuccess: boolean, apiServices: ApiServices, object: Obj, instance: FieldValues, action?: Action, setSnackbarError?: React.Dispatch<React.SetStateAction<{
63
61
  showAlert: boolean;
64
62
  message?: string;
@@ -87,7 +85,7 @@ export declare function formatSubmission(submission: FieldValues, apiServices?:
87
85
  objectId?: string;
88
86
  }, parameters?: InputParameter[]): Promise<FieldValues>;
89
87
  export declare function filterEmptySections(entry: Sections | Columns, instance?: FieldValues, formData?: FieldValues): Sections | Columns | null;
90
- export declare function assignIdsToSectionsAndRichText(entries: FormEntry[], object: Obj, parameters?: InputParameter[]): FormEntry[];
88
+ export declare function assignIdsToSectionsAndRichText(entries: FormEntry[] | PanelViewEntry[], object: Obj, parameters?: InputParameter[]): FormEntry[] | PanelViewEntry[];
91
89
  /**
92
90
  * Converts a plain text string to RTF format suitable for a RichTextEditor.
93
91
  *
@@ -101,5 +99,6 @@ export declare function assignIdsToSectionsAndRichText(entries: FormEntry[], obj
101
99
  * This ensures that any plain text input will be safely represented in RTF without losing formatting or characters.
102
100
  */
103
101
  export declare function plainTextToRtf(plainText: string): string;
104
- export declare function getFieldDefinition(entry: FormEntry, object: Obj, parameters?: InputParameter[]): InputParameter | Property | undefined;
102
+ export declare function getFieldDefinition(entry: FormEntry | PanelViewEntry, object: Obj, parameters?: InputParameter[]): InputParameter | Property | undefined;
105
103
  export declare function obfuscateValue(value: unknown, property?: Partial<Property> | Partial<ObjectProperty>): unknown;
104
+ export declare function useFormById(formId: string, apiServices: ApiServices, errorMessage?: string): import("@tanstack/react-query/build/legacy/types").UseQueryResult<EvokeForm, Error>;
@@ -1,4 +1,5 @@
1
1
  import { LocalDateTime } from '@js-joda/core';
2
+ import { useQuery } from '@tanstack/react-query';
2
3
  import { jsonLogic } from 'json-logic-js-graphql';
3
4
  import { get, isArray, isEmpty, isObject, omit, pick, startCase, transform } from 'lodash';
4
5
  import { DateTime } from 'luxon';
@@ -127,7 +128,15 @@ const getEntryType = (entry, parameters) => {
127
128
  };
128
129
  export function getPrefixedUrl(url) {
129
130
  const wcsMatchers = ['/apps', '/pages', '/widgets'];
130
- const dataMatchers = ['/objects', '/correspondenceTemplates', '/documents', '/payments', '/forms', '/locations'];
131
+ const dataMatchers = [
132
+ '/objects',
133
+ '/correspondenceTemplates',
134
+ '/documents',
135
+ '/payments',
136
+ '/forms',
137
+ '/locations',
138
+ '/files',
139
+ ];
131
140
  const signalrMatchers = ['/hubs'];
132
141
  const accessManagementMatchers = ['/users'];
133
142
  const workflowMatchers = ['/workflows'];
@@ -342,31 +351,19 @@ export function updateCriteriaInputs(criteria, data, user, instance) {
342
351
  },
343
352
  }));
344
353
  }
345
- export async function fetchCollectionData(apiServices, fieldDefinition, setFetchedOptions, instanceId, fetchedOptions, initialMiddleObjectInstances) {
346
- try {
347
- if ((fetchedOptions && !fetchedOptions[`${fieldDefinition.id}MiddleObject`]) || !fetchedOptions) {
348
- const fetchedMiddleObject = await apiServices.get(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/effective?sanitizedVersion=true`), {
349
- params: {
350
- filter: { fields: ['properties', 'actions', 'rootObjectId'] },
351
- },
352
- });
353
- setFetchedOptions({ [`${fieldDefinition.id}MiddleObject`]: fetchedMiddleObject });
354
- }
355
- // Fetch the initial middle object instances
356
- const filter = instanceId ? getMiddleObjectFilter(fieldDefinition, instanceId) : {};
357
- if (!isArray(initialMiddleObjectInstances)) {
358
- const fetchedInitialMiddleObjectInstances = await apiServices.get(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances`), {
359
- params: { filter: JSON.stringify(filter) },
360
- });
361
- setFetchedOptions({
362
- [`${fieldDefinition.id}InitialMiddleObjectInstances`]: fetchedInitialMiddleObjectInstances,
363
- });
364
- }
365
- }
366
- catch (error) {
367
- console.error('Error fetching collection data:', error);
368
- }
369
- }
354
+ export const fetchMiddleObject = async (fieldDefinition, apiServices) => {
355
+ return await apiServices.get(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/effective?sanitizedVersion=true`), {
356
+ params: {
357
+ filter: { fields: ['properties', 'actions', 'rootObjectId'] },
358
+ },
359
+ });
360
+ };
361
+ export const fetchInitialMiddleObjectInstances = async (apiServices, fieldDefinition, instanceId) => {
362
+ const filter = getMiddleObjectFilter(fieldDefinition, instanceId);
363
+ return await apiServices.get(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances`), {
364
+ params: { filter: JSON.stringify(filter) },
365
+ });
366
+ };
370
367
  export const getErrorCountForSection = (section, errors) => {
371
368
  const entries = section.entries || [];
372
369
  return isArray(section.entries)
@@ -479,53 +476,90 @@ export const docProperties = [
479
476
  type: 'string',
480
477
  },
481
478
  ];
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) => {
479
+ export const uploadFiles = async (files, apiServices, actionId = '_create', linkTo, shortCircuit = true) => {
486
480
  // Separate already uploaded files from files that need uploading
487
481
  const alreadyUploaded = files.filter((file) => !('size' in file));
488
482
  const filesToUpload = files.filter((file) => 'size' in file);
489
- // Upload all files in parallel
490
- const uploadPromises = filesToUpload.map(async (file) => {
483
+ let failedUpload = false;
484
+ // Upload all files in parallel, handling each result individually
485
+ const uploadPromises = [];
486
+ for (const file of filesToUpload) {
491
487
  const formData = new FormData();
492
488
  formData.append('file', file);
493
489
  formData.append('actionId', actionId);
494
490
  formData.append('objectId', 'sys__file');
495
- if (metadata) {
496
- for (const [key, value] of Object.entries(metadata)) {
497
- formData.append(key, value);
498
- }
499
- }
491
+ formData.append('input', JSON.stringify({ name: file.name, contentType: file.type }));
500
492
  if (linkTo) {
501
493
  formData.append('linkTo', JSON.stringify(linkTo));
502
494
  }
503
- const fileInstance = await apiServices.post(getPrefixedUrl(`/files`), formData);
495
+ const uploadPromise = (async () => {
496
+ try {
497
+ const fileInstance = await apiServices.post(getPrefixedUrl(`/files`), formData);
498
+ return {
499
+ id: fileInstance.id,
500
+ name: fileInstance.name,
501
+ unsaved: true,
502
+ };
503
+ }
504
+ catch (error) {
505
+ console.error(`Failed to upload file ${file.name}:`, error);
506
+ failedUpload = true;
507
+ }
508
+ })();
509
+ uploadPromises.push(uploadPromise);
510
+ }
511
+ if (!shortCircuit) {
512
+ // Wait for all upload attempts to complete (successes and failures)
513
+ const uploadResults = await Promise.allSettled(uploadPromises);
514
+ const uploadedFiles = uploadResults
515
+ .filter((result) => result.status === 'fulfilled' && !!result.value)
516
+ .map((result) => result.value);
517
+ const failedCount = filesToUpload.length - uploadedFiles.length;
504
518
  return {
505
- id: fileInstance.id,
506
- name: fileInstance.name,
519
+ successfulUploads: [...alreadyUploaded, ...uploadedFiles],
520
+ errorMessage: failedCount > 0 ? `Failed to upload ${failedCount} file(s)` : undefined,
507
521
  };
508
- });
522
+ }
509
523
  const uploadedFiles = await Promise.all(uploadPromises);
510
- return [...alreadyUploaded, ...uploadedFiles];
524
+ if (failedUpload) {
525
+ return {
526
+ successfulUploads: [],
527
+ errorMessage: 'An error occurred when uploading files',
528
+ };
529
+ }
530
+ return {
531
+ successfulUploads: [...alreadyUploaded, ...uploadedFiles],
532
+ };
511
533
  };
512
534
  /**
513
535
  * Creates file links for uploaded files by calling the objects endpoint with sys__fileLink
514
536
  * This is used after instance creation when the instance ID becomes available
515
537
  */
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);
538
+ export const createFileLinks = async (fileReferences, linkedInstance, apiServices) => {
539
+ // Link files in parallel, handling each result individually
540
+ const linkPromises = [];
541
+ for (const file of fileReferences) {
542
+ const linkPromise = (async () => {
543
+ try {
544
+ await apiServices.post(getPrefixedUrl(`/objects/sys__fileLink/instances`), {
545
+ name: 'File Link',
546
+ file: { id: file.id, name: file.name },
547
+ linkedInstance,
548
+ }, {
549
+ params: {
550
+ actionId: '_create',
551
+ },
552
+ });
553
+ }
554
+ catch (error) {
555
+ console.error(`Failed to create file link for ${file.name}:`, error);
556
+ // The file remains unlinked and can be retried later
557
+ }
558
+ })();
559
+ linkPromises.push(linkPromise);
560
+ }
561
+ // Wait for all linking attempts to complete (successes and failures)
562
+ await Promise.allSettled(linkPromises);
529
563
  };
530
564
  export const uploadDocuments = async (files, metadata, apiServices, instanceId, objectId) => {
531
565
  const allDocuments = [];
@@ -553,20 +587,29 @@ export const uploadDocuments = async (files, metadata, apiServices, instanceId,
553
587
  export const deleteDocuments = async (submittedFields, requestSuccess, apiServices, object, instance, action, setSnackbarError) => {
554
588
  const documentProperties = action?.parameters
555
589
  ? action.parameters.filter((param) => ['document', 'file'].includes(param.type))
556
- : object?.properties?.filter((prop) => ['document', 'file'].includes(prop.type));
590
+ : object.properties?.filter((prop) => ['document', 'file'].includes(prop.type));
557
591
  for (const docProperty of documentProperties ?? []) {
558
592
  const savedValue = submittedFields[docProperty.id];
559
- const originalValue = instance?.[docProperty.id];
593
+ const originalValue = instance[docProperty.id];
560
594
  const documentsToRemove = requestSuccess
561
595
  ? (originalValue?.filter((file) => !savedValue?.some((f) => f.id === file.id)) ?? [])
562
596
  : (savedValue?.filter((file) => !originalValue?.some((f) => f.id === file.id)) ?? []);
563
597
  for (const doc of documentsToRemove) {
564
598
  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);
599
+ // Build context for state model
600
+ const fieldType = docProperty.type === 'file' ? 'file' : 'document';
601
+ if (fieldType === 'file') {
602
+ // For file properties, unlink the file. Don't delete the actual file
603
+ // since other instances may be using it.
604
+ await apiServices.post(getPrefixedUrl(`/files/${doc.id}/unlinkInstance`), {
605
+ linkedInstanceId: instance.id,
606
+ linkedObjectId: object.id,
607
+ });
608
+ }
609
+ else {
610
+ // For document properties, delete the document
611
+ await apiServices.delete(getPrefixedUrl(`/objects/${object.id}/instances/${instance.id}/documents/${doc.id}`));
612
+ }
570
613
  }
571
614
  catch (error) {
572
615
  if (error) {
@@ -581,6 +624,28 @@ export const deleteDocuments = async (submittedFields, requestSuccess, apiServic
581
624
  }
582
625
  }
583
626
  };
627
+ async function handleUploads(files, propertyType, entry, apiServices, objectId, instanceId) {
628
+ if (propertyType === 'file') {
629
+ // Get the createActionId from display options, default to '_create'
630
+ const createActionId = entry?.display?.createActionId ?? '_create';
631
+ return await uploadFiles(files, apiServices, createActionId, instanceId ? { id: instanceId, objectId } : undefined);
632
+ }
633
+ else if (propertyType === 'document' && instanceId) {
634
+ try {
635
+ const docs = await uploadDocuments(files, {
636
+ type: '',
637
+ view_permission: '',
638
+ ...entry?.documentMetadata,
639
+ }, apiServices, instanceId, objectId);
640
+ return { successfulUploads: docs };
641
+ }
642
+ catch (error) {
643
+ console.error('Error uploading documents:', error);
644
+ return { successfulUploads: [], errorMessage: 'Error uploading documents' };
645
+ }
646
+ }
647
+ return { successfulUploads: [] };
648
+ }
584
649
  /**
585
650
  * Transforms a form submission into a format safe for API submission.
586
651
  *
@@ -598,44 +663,44 @@ export async function formatSubmission(submission, apiServices, objectId, instan
598
663
  if (associatedObject) {
599
664
  delete submission[associatedObject.propertyId];
600
665
  }
601
- const allEntries = getUnnestedEntries(form?.entries ?? []) ?? [];
666
+ const allEntries = getUnnestedEntries(form?.entries ?? []);
602
667
  for (const [key, value] of Object.entries(submission)) {
603
668
  const entry = allEntries?.find((entry) => getEntryId(entry) === key);
604
669
  if (isArray(value)) {
605
- // Only upload if array contains File instances (not SavedDocumentReference)
670
+ const propertyType = getEntryType(entry, parameters);
671
+ // The only array types we need to handle specially are 'file' and 'document'.
672
+ if (propertyType !== 'file' && propertyType !== 'document') {
673
+ continue;
674
+ }
675
+ // Only upload if array contains File instances (not SavedDocumentReference).
606
676
  const fileInArray = value.some((item) => item instanceof File);
607
677
  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
- }
678
+ const result = await handleUploads(value, propertyType ?? '', entry, apiServices, objectId, instanceId);
679
+ if (result.errorMessage) {
680
+ setSnackbarError?.({
681
+ showAlert: true,
682
+ // Provide generic message since we're ignoring a partial upload.
683
+ message: `An error occurred while uploading associated ${propertyType === 'file' ? 'files' : 'documents'}`,
684
+ isError: true,
685
+ });
637
686
  return submission;
638
687
  }
688
+ else {
689
+ // Filter out 'unsaved' flag before submission since the api doesn't know about it.
690
+ submission[key] = result.successfulUploads.map((file) => ({ id: file.id, name: file.name }));
691
+ }
692
+ }
693
+ else {
694
+ // Filter out 'unsaved' flag before submission since the api doesn't know about it.
695
+ submission[key] = value
696
+ // This should never happen but it's possible that the else branch
697
+ // is reached because either 'apiServices' or 'objectId' is undefined.
698
+ // If that's the case the submission will fail if we submit File blobs.
699
+ .filter((file) => !(file instanceof File))
700
+ .map((file) => ({
701
+ id: file.id,
702
+ name: file.name,
703
+ }));
639
704
  }
640
705
  // if there are address fields with no value address needs to be set to undefined to be able to submit
641
706
  }
@@ -882,3 +947,14 @@ function applyMaskToObfuscatedValue(value, mask) {
882
947
  }
883
948
  return maskedValue;
884
949
  }
950
+ export function useFormById(formId, apiServices, errorMessage) {
951
+ return useQuery({
952
+ queryKey: ['form', formId],
953
+ enabled: formId !== '_auto_' && !!formId,
954
+ staleTime: Infinity,
955
+ queryFn: () => apiServices.get(getPrefixedUrl(`/forms/${formId}`)),
956
+ meta: {
957
+ errorMessage,
958
+ },
959
+ });
960
+ }