@evoke-platform/ui-components 1.17.0 → 1.18.0

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 (50) hide show
  1. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.d.ts +8 -4
  2. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.js +238 -141
  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 +3 -1
  15. package/dist/published/components/custom/FormV2/FormRenderer.js +9 -4
  16. package/dist/published/components/custom/FormV2/FormRendererContainer.d.ts +5 -0
  17. package/dist/published/components/custom/FormV2/FormRendererContainer.js +116 -53
  18. package/dist/published/components/custom/FormV2/components/Body.d.ts +1 -0
  19. package/dist/published/components/custom/FormV2/components/Body.js +4 -2
  20. package/dist/published/components/custom/FormV2/components/FormContext.d.ts +1 -0
  21. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/ActionDialog.d.ts +1 -0
  22. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +46 -17
  23. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.d.ts +3 -2
  24. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +44 -11
  25. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.d.ts +4 -3
  26. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/DocumentList.js +41 -29
  27. package/dist/published/components/custom/FormV2/components/FormFieldTypes/FileContent.d.ts +12 -0
  28. package/dist/published/components/custom/FormV2/components/FormFieldTypes/FileContent.js +197 -0
  29. package/dist/published/components/custom/FormV2/components/FormFieldTypes/relatedObjectFiles/InstanceLookup.js +14 -0
  30. package/dist/published/components/custom/FormV2/components/FormletRenderer.d.ts +1 -0
  31. package/dist/published/components/custom/FormV2/components/FormletRenderer.js +6 -14
  32. package/dist/published/components/custom/FormV2/components/HtmlView.js +4 -0
  33. package/dist/published/components/custom/FormV2/components/MisconfiguredErrorMessage.d.ts +2 -0
  34. package/dist/published/components/custom/FormV2/components/MisconfiguredErrorMessage.js +15 -0
  35. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +18 -19
  36. package/dist/published/components/custom/FormV2/components/types.d.ts +8 -2
  37. package/dist/published/components/custom/FormV2/components/utils.d.ts +11 -8
  38. package/dist/published/components/custom/FormV2/components/utils.js +194 -78
  39. package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +180 -2
  40. package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +212 -0
  41. package/dist/published/components/custom/ViewDetailsV2/InstanceEntryRenderer.js +26 -10
  42. package/dist/published/components/custom/ViewDetailsV2/ViewDetailsV2Container.js +1 -0
  43. package/dist/published/components/custom/index.d.ts +1 -0
  44. package/dist/published/index.d.ts +1 -1
  45. package/dist/published/stories/CriteriaBuilder.stories.js +70 -22
  46. package/dist/published/stories/FormRenderer.stories.d.ts +9 -3
  47. package/dist/published/stories/FormRenderer.stories.js +1 -0
  48. package/dist/published/stories/FormRendererContainer.stories.d.ts +25 -0
  49. package/dist/published/theme/hooks.d.ts +1 -0
  50. package/package.json +3 -2
@@ -1,6 +1,6 @@
1
1
  import { useApiServices, useNotification, } from '@evoke-platform/context';
2
2
  import { useQuery, useQueryClient } from '@tanstack/react-query';
3
- import { get, omit, startCase } from 'lodash';
3
+ import { get, pick, startCase } from 'lodash';
4
4
  import { DateTime } from 'luxon';
5
5
  import React, { useCallback, useEffect, useMemo, useState } from 'react';
6
6
  import sift from 'sift';
@@ -10,7 +10,7 @@ import { Accordion, AccordionDetails, AccordionSummary, Button, IconButton, Skel
10
10
  import { Box } from '../../../../../layout';
11
11
  import { getReadableQuery } from '../../../../CriteriaBuilder';
12
12
  import { retrieveCustomErrorMessage } from '../../../../Form/utils';
13
- import { convertPropertiesToParams, deleteDocuments, formatSubmission, getPrefixedUrl, transformToWhere, useFormById, } from '../../utils';
13
+ import { convertPropertiesToParams, deleteDocuments, formatSubmission, getPrefixedUrl, handleFileUpload, transformToWhere, useFormById, } from '../../utils';
14
14
  import { ActionDialog } from './ActionDialog';
15
15
  import { DocumentViewerCell } from './DocumentViewerCell';
16
16
  const styles = {
@@ -258,20 +258,34 @@ const RepeatableField = (props) => {
258
258
  : dialogType === 'update'
259
259
  ? entry.display?.updateActionId
260
260
  : entry.display?.deleteActionId));
261
+ const relatedProperty = relatedObject?.properties?.find((p) => p.id === fieldDefinition.relatedPropertyId);
261
262
  // when save is called we know that fieldDefinition is a parameter and fieldDefinition.objectId is defined
262
- input = await formatSubmission(input, apiServices, fieldDefinition.objectId, selectedInstanceId, action?.type === 'update' ? updateForm : undefined, undefined, instance?.id && fieldDefinition.relatedPropertyId
263
- ? { instanceId: instance.id, propertyId: fieldDefinition.relatedPropertyId }
263
+ input = await formatSubmission(input, apiServices, fieldDefinition.objectId, selectedInstanceId, action?.type === 'update' ? updateForm : action?.type === 'delete' ? deleteForm : createForm, undefined, instance?.id && fieldDefinition.relatedPropertyId
264
+ ? {
265
+ instanceId: instance.id,
266
+ propertyId: fieldDefinition.relatedPropertyId,
267
+ objectId: !relatedProperty?.objectId ? instance.objectId : undefined,
268
+ }
264
269
  : undefined, action?.parameters ?? (relatedObject && convertPropertiesToParams(relatedObject)));
265
- if (action?.type === 'create' && entry.display?.createActionId) {
270
+ if (action?.type === 'create' && action.id) {
266
271
  const updatedInput = {
267
272
  ...input,
268
- [fieldDefinition?.relatedPropertyId]: { id: instance?.id },
273
+ [fieldDefinition?.relatedPropertyId]: {
274
+ id: instance?.id,
275
+ objectId: !relatedProperty?.objectId ? instance?.objectId : undefined,
276
+ },
269
277
  };
270
278
  try {
271
- const instance = await apiServices.post(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances/actions`), {
272
- actionId: entry.display?.createActionId,
273
- input: updatedInput,
274
- });
279
+ let instance = undefined;
280
+ if (relatedObject?.rootObjectId === 'sys__file') {
281
+ instance = await handleFileUpload(apiServices, updatedInput, action.id, fieldDefinition.objectId);
282
+ }
283
+ else {
284
+ instance = await apiServices.post(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances/actions`), {
285
+ actionId: action.id,
286
+ input: updatedInput,
287
+ });
288
+ }
275
289
  queryClient.setQueryData(relatedInstancesQueryKey, (oldData) => {
276
290
  if (!oldData)
277
291
  return [instance];
@@ -293,12 +307,21 @@ const RepeatableField = (props) => {
293
307
  else {
294
308
  const relatedObjectId = relatedObject?.id;
295
309
  try {
296
- const response = await apiServices.post(getPrefixedUrl(`/objects/${relatedObjectId}/instances/${selectedInstanceId}/actions`), {
297
- actionId: `_${action?.type}`,
298
- input: omit(input, relatedObject?.properties
299
- ?.filter((property) => property.formula || property.type === 'collection')
300
- .map((property) => property.id) ?? []),
301
- });
310
+ let response = undefined;
311
+ const actionParameters = action?.parameters ?? (relatedObject ? convertPropertiesToParams(relatedObject) : undefined) ?? [];
312
+ const allowedFieldIds = actionParameters
313
+ .filter((param) => param.type !== 'collection')
314
+ .map((param) => param.id);
315
+ const submission = pick(input, allowedFieldIds);
316
+ if (relatedObject?.rootObjectId === 'sys__file' && action?.id && action?.type !== 'delete') {
317
+ response = await handleFileUpload(apiServices, submission, action.id, relatedObjectId, selectedInstanceId);
318
+ }
319
+ else {
320
+ response = await apiServices.post(getPrefixedUrl(`/objects/${relatedObjectId}/instances/${selectedInstanceId}/actions`), {
321
+ actionId: action?.id ?? `_${action?.type}`,
322
+ input: submission,
323
+ });
324
+ }
302
325
  if (response && relatedObject && instance) {
303
326
  deleteDocuments(input, !!response, apiServices, relatedObject, instance, action);
304
327
  }
@@ -491,7 +514,13 @@ const RepeatableField = (props) => {
491
514
  ? '_auto_'
492
515
  : deleteForm?.id
493
516
  : undefined, instanceId: selectedInstanceId, relatedParameter: fieldDefinition, associatedObject: instance?.id && fieldDefinition.relatedPropertyId
494
- ? { instanceId: instance.id, propertyId: fieldDefinition.relatedPropertyId }
517
+ ? {
518
+ instanceId: instance.id,
519
+ propertyId: fieldDefinition.relatedPropertyId,
520
+ objectId: !relatedObject?.properties?.find((p) => p.id === fieldDefinition.relatedPropertyId)?.objectId
521
+ ? instance.objectId
522
+ : undefined,
523
+ }
495
524
  : undefined })),
496
525
  React.createElement(Snackbar, { open: snackbarError.showAlert, handleClose: () => setSnackbarError({ isError: snackbarError.isError, showAlert: false }), message: snackbarError.message, error: snackbarError.isError })));
497
526
  };
@@ -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 { useQuery } from '@tanstack/react-query';
3
3
  import prettyBytes from 'pretty-bytes';
4
4
  import React, { 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 { object, handleChange, onAutosave: onAutosave, instance } = useFormContext();
15
+ const { object, handleChange, onAutosave: onAutosave, instance, form } = useFormContext();
16
16
  const [snackbarError, setSnackbarError] = useState();
17
17
  const [documents, setDocuments] = useState();
18
18
  let allowedTypesMessage = '';
@@ -32,11 +32,22 @@ export const Document = (props) => {
32
32
  useEffect(() => {
33
33
  setDocuments(value);
34
34
  }, [value]);
35
+ // Find the entry to get the configured createActionId and fileObjectId
36
+ const allEntries = getUnnestedEntries(form?.entries ?? []) ?? [];
37
+ const entry = allEntries?.find((entry) => getEntryId(entry) === id);
38
+ const createActionId = entry?.display?.createActionId ?? '_create';
39
+ // Get the configured objectId from display.fileObjectId, defaulting to sys__file
40
+ const fileObjectId = entry?.display?.fileObjectId ?? 'sys__file';
41
+ // For 'file' type properties, check regular object instance permissions
42
+ // For 'document' type properties, check document attachment permissions
43
+ const endpoint = fieldType === 'file'
44
+ ? getPrefixedUrl(`/objects/${fileObjectId}/instances/checkAccess?action=execute&field=${createActionId}`)
45
+ : getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/checkAccess?action=update`);
35
46
  const { data: hasUpdatePermission = false, isLoading } = useQuery({
36
- queryKey: ['hasDocUpdatePermission', object?.id, instance?.id],
47
+ queryKey: ['hasUpdatePermission', object?.id, instance?.id, fieldType, id],
37
48
  queryFn: async () => {
38
49
  try {
39
- const accessCheck = await apiServices.get(getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/checkAccess?action=update`));
50
+ const accessCheck = await apiServices.get(endpoint);
40
51
  return accessCheck.result;
41
52
  }
42
53
  catch {
@@ -44,14 +55,36 @@ export const Document = (props) => {
44
55
  }
45
56
  },
46
57
  staleTime: Infinity,
47
- enabled: canUpdateProperty && !!instance?.id && !!object?.id,
58
+ // For 'file' type fields the permission endpoint only requires the object ID, so the
59
+ // query can run on create actions where no instance exists yet. For 'document' type
60
+ // fields the endpoint is instance-scoped, so instance ID is still required.
61
+ enabled: canUpdateProperty && !!object?.id && (fieldType === 'file' || !!instance?.id),
48
62
  });
49
63
  const handleUpload = async (files) => {
50
- // Store File objects in form state - they will be uploaded during autosave via formatSubmission()
51
- const newDocuments = [...(documents ?? []), ...(files ?? [])];
64
+ if (!files?.length) {
65
+ return;
66
+ }
67
+ let uploadedFiles = files;
68
+ // Get the createActionId and fileObjectId from form entry, default to '_create' and 'sys__file'
69
+ const allEntries = getUnnestedEntries(form?.entries ?? []);
70
+ const entry = allEntries?.find((entry) => getEntryId(entry) === id);
71
+ const createActionId = entry?.display?.createActionId ?? '_create';
72
+ const fileObjectId = entry?.display?.fileObjectId ?? 'sys__file';
73
+ // Immediately upload files for 'file' type properties when autosave is not enabled.
74
+ // Linking will happen upon final submission.
75
+ // If autosave is enabled, upload and linking will happen in the autosave handler.
76
+ if (fieldType === 'file' && !onAutosave) {
77
+ const { successfulUploads, errorMessage } = await uploadFiles(files, apiServices, createActionId, fileObjectId, undefined, false);
78
+ uploadedFiles = successfulUploads;
79
+ if (errorMessage) {
80
+ setSnackbarError({ message: errorMessage, type: 'error' });
81
+ }
82
+ }
83
+ // Store uploaded file references (or File objects) in form state
84
+ const newDocuments = [...(documents ?? []), ...uploadedFiles];
52
85
  setDocuments(newDocuments);
53
86
  try {
54
- handleChange && (await handleChange(id, newDocuments));
87
+ await handleChange?.(id, newDocuments);
55
88
  }
56
89
  catch (error) {
57
90
  console.error('Failed to update field:', error);
@@ -120,7 +153,7 @@ export const Document = (props) => {
120
153
  } }, validate?.maxDocuments === 1
121
154
  ? `Maximum size is ${formattedMaxSize}.`
122
155
  : `The maximum size of each document is ${formattedMaxSize}.`)))))),
123
- canUpdateProperty && isLoading ? (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: documents, setSnackbarError: (type, message) => setSnackbarError({ message, type }), canUpdateProperty: canUpdateProperty && !!hasUpdatePermission })),
156
+ canUpdateProperty && isLoading ? (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 })),
124
157
  React.createElement(Snackbar, { open: !!snackbarError?.message, handleClose: () => setSnackbarError(null), message: snackbarError?.message, error: snackbarError?.type === 'error' }),
125
158
  errors.length > 0 && (React.createElement(Box, { display: 'flex', alignItems: 'center' },
126
159
  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,6 +1,5 @@
1
1
  import { useApiServices } from '@evoke-platform/context';
2
2
  import { useQuery } from '@tanstack/react-query';
3
- import { isEqual } from 'lodash';
4
3
  import prettyBytes from 'pretty-bytes';
5
4
  import React, { useEffect, useState } from 'react';
6
5
  import { FileWithExtension, LaunchRounded, TrashCan, WarningRounded } from '../../../../../../icons';
@@ -25,33 +24,44 @@ const viewableFileTypes = [
25
24
  'text/plain',
26
25
  ];
27
26
  export const DocumentList = (props) => {
28
- const { handleChange, onAutosave, id, canUpdateProperty, value: documents, setSnackbarError } = props;
27
+ const { handleChange, onAutosave, id, fieldType = 'document', canUpdateProperty, value: documents, setSnackbarError, } = props;
29
28
  const apiServices = useApiServices();
30
29
  const { fetchedOptions, setFetchedOptions, object, instance } = useFormContext();
31
30
  // Determine property type once at component level
32
- const propertyType = object?.properties?.find((p) => p.id === id)?.type;
33
- const isFileType = propertyType === 'file';
31
+ const isFileType = fieldType === 'file';
34
32
  // savedDocuments is either FileInstance[] or DocumentType[], never a mix
35
33
  const [savedDocuments, setSavedDocuments] = useState(fetchedOptions[`${id}SavedDocuments`]);
34
+ const [deletedDocumentIds, setDeletedDocumentIds] = useState([]);
35
+ // The stable list we render — only updated once savedDocuments has metadata for
36
+ // every referenced ID. While waiting for metadata, the previous list is kept so
37
+ // the UI doesn't flash empty sizes, missing icons, or "Deleted" chips.
38
+ const [displayDocuments, setDisplayDocuments] = useState(documents);
36
39
  useEffect(() => {
37
- const currentValue = instance?.[id];
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);
40
+ const currentDocumentIds = (documents ?? [])
41
+ .filter((doc) => !(doc instanceof File))
42
+ .map((doc) => doc.id);
43
+ if (!currentDocumentIds.length) {
44
+ // Only File objects metadata not needed, display immediately.
45
+ setDisplayDocuments(documents);
46
+ return;
47
+ }
48
+ const savedDocumentIds = savedDocuments?.map((doc) => doc.id) ?? [];
49
+ const missingDocumentIds = currentDocumentIds.filter((docId) => !savedDocumentIds.includes(docId) && !deletedDocumentIds.includes(docId));
50
+ if (missingDocumentIds.length) {
51
+ // Metadata not yet available — fetch it.
52
+ // If we already have a stable display, keep it to avoid flashing.
53
+ // If there's nothing to show yet (initial load), display immediately so
54
+ // the file names are visible while metadata loads.
55
+ if (!displayDocuments?.length) {
56
+ setDisplayDocuments(documents);
47
57
  }
58
+ getDocuments(currentDocumentIds);
48
59
  }
49
- }, [id, documents, object]);
50
- useEffect(() => {
51
- if (fetchedOptions[`${id}SavedDocuments`]) {
52
- setSavedDocuments(fetchedOptions[`${id}SavedDocuments`]);
60
+ else {
61
+ // All IDs have metadata — safe to update the display.
62
+ setDisplayDocuments(documents);
53
63
  }
54
- }, [fetchedOptions]);
64
+ }, [documents, savedDocuments, fieldType, apiServices]);
55
65
  const getDocuments = (currentDocumentIds, shouldRetry = true) => {
56
66
  // For 'file' type properties, fetch sys__file instances directly
57
67
  // For 'document' type properties, fetch attachment documents
@@ -64,7 +74,7 @@ export const DocumentList = (props) => {
64
74
  // There is a short delay between when a document is uploaded and when
65
75
  // it is indexed. Therefore, try again if documents are not found.
66
76
  if (shouldRetry &&
67
- (!docs ||
77
+ (!docs?.length ||
68
78
  currentDocumentIds.some((docId) => !docs.find((doc) => docId === doc.id)))) {
69
79
  setTimeout(() => getDocuments(currentDocumentIds, false), 2000);
70
80
  }
@@ -72,6 +82,9 @@ export const DocumentList = (props) => {
72
82
  setSnackbarError('error', 'Error occurred while retrieving saved documents');
73
83
  }
74
84
  else {
85
+ // IDs that were requested but not returned by the server = deleted/not found.
86
+ const deletedIds = currentDocumentIds.filter((docId) => !docs?.find((doc) => doc.id === docId));
87
+ setDeletedDocumentIds(deletedIds);
75
88
  setSavedDocuments(docs);
76
89
  setFetchedOptions({
77
90
  [`${id}SavedDocuments`]: docs,
@@ -97,12 +110,13 @@ export const DocumentList = (props) => {
97
110
  staleTime: Infinity,
98
111
  });
99
112
  const isFile = (doc) => doc instanceof File;
113
+ const isUnsavedFile = (doc) => isFile(doc) || !!doc.unsaved;
100
114
  const fileExists = (doc) => savedDocuments?.find((d) => d.id === doc.id);
101
115
  const handleRemove = async (index) => {
102
116
  const updatedDocuments = documents?.filter((_, i) => i !== index) ?? [];
103
117
  const newValue = updatedDocuments.length === 0 ? undefined : updatedDocuments;
104
118
  try {
105
- handleChange && (await handleChange(id, newValue));
119
+ handleChange?.(id, newValue);
106
120
  }
107
121
  catch (error) {
108
122
  console.error('Failed to update field:', error);
@@ -116,7 +130,7 @@ export const DocumentList = (props) => {
116
130
  }
117
131
  };
118
132
  const openDocument = async (index) => {
119
- const doc = documents?.[index];
133
+ const doc = displayDocuments?.[index];
120
134
  if (doc) {
121
135
  let url;
122
136
  const contentType = doc instanceof File
@@ -124,9 +138,7 @@ export const DocumentList = (props) => {
124
138
  : savedDocuments?.find((savedDocument) => savedDocument.id === doc.id)?.contentType;
125
139
  if (!isFile(doc)) {
126
140
  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'
141
+ const contentEndpoint = isFileType
130
142
  ? getPrefixedUrl(`/files/${doc.id}/content`)
131
143
  : getPrefixedUrl(`/objects/${object?.id}/instances/${instance?.id}/documents/${doc.id}/content`);
132
144
  const documentResponse = await apiServices.get(contentEndpoint, { responseType: 'blob' });
@@ -167,8 +179,8 @@ export const DocumentList = (props) => {
167
179
  return size;
168
180
  };
169
181
  return (React.createElement(React.Fragment, null,
170
- !documents && !canUpdateProperty && (React.createElement(Typography, { variant: "body2", sx: { color: '#637381' } }, "No documents")),
171
- !!documents?.length && (React.createElement(Box, null, documents.map((doc, index) => (React.createElement(Grid, { container: true, sx: {
182
+ !displayDocuments && !canUpdateProperty && (React.createElement(Typography, { variant: "body2", sx: { color: '#637381' } }, "No documents")),
183
+ !!displayDocuments?.length && (React.createElement(Box, null, displayDocuments.map((doc, index) => (React.createElement(Grid, { container: true, sx: {
172
184
  width: '100%',
173
185
  border: '1px solid #C4CDD5',
174
186
  borderRadius: '6px',
@@ -189,10 +201,10 @@ export const DocumentList = (props) => {
189
201
  } }, doc.name)),
190
202
  React.createElement(Grid, { item: true, xs: 12 },
191
203
  React.createElement(Typography, { sx: { fontSize: '12px', color: '#637381' } }, getDocumentSize(doc)))),
192
- (isFile(doc) || (hasViewPermission && !isFile(doc) && fileExists(doc))) && (React.createElement(Grid, { item: true },
204
+ (isUnsavedFile(doc) || (hasViewPermission && !isFile(doc) && fileExists(doc))) && (React.createElement(Grid, { item: true },
193
205
  React.createElement(IconButton, { "aria-label": "open document", sx: { ...styles.icon, marginRight: '16px' }, onClick: () => openDocument(index) },
194
206
  React.createElement(LaunchRounded, { sx: { color: '#637381', fontSize: '22px' } })))),
195
- !isFile(doc) && savedDocuments && !fileExists(doc) && (React.createElement(Chip, { label: "Deleted", sx: {
207
+ !isFile(doc) && !isUnsavedFile(doc) && savedDocuments && !fileExists(doc) && (React.createElement(Chip, { label: "Deleted", sx: {
196
208
  marginRight: '16px',
197
209
  backgroundColor: 'rgba(222, 48, 36, 0.16)',
198
210
  color: '#A91813',
@@ -0,0 +1,12 @@
1
+ import { DocumentParameterValidation } from '@evoke-platform/context';
2
+ import React from 'react';
3
+ type FileContentProps = {
4
+ id: string;
5
+ canUpdateProperty: boolean;
6
+ error: boolean;
7
+ validate?: DocumentParameterValidation;
8
+ value: File | undefined;
9
+ hasDescription?: boolean;
10
+ };
11
+ export declare const FileContent: (props: FileContentProps) => React.JSX.Element;
12
+ export {};
@@ -0,0 +1,197 @@
1
+ import { useApiServices } from '@evoke-platform/context';
2
+ import prettyBytes from 'pretty-bytes';
3
+ import React, { useCallback, useEffect, useState } from 'react';
4
+ import { useDropzone } from 'react-dropzone';
5
+ import { FileWithExtension, InfoRounded, LaunchRounded, TrashCan, UploadCloud } from '../../../../../icons';
6
+ import { useFormContext } from '../../../../../theme/hooks';
7
+ import { IconButton, Snackbar, Typography } from '../../../../core';
8
+ import { Box, Grid } from '../../../../layout';
9
+ import { getPrefixedUrl } from '../utils';
10
+ import mime from 'mime';
11
+ export const FileContent = (props) => {
12
+ const { id, canUpdateProperty, error, validate, hasDescription, value } = props;
13
+ const { handleChange, instance } = useFormContext();
14
+ const apiServices = useApiServices();
15
+ const [snackbarError, setSnackbarError] = useState();
16
+ const [file, setFile] = useState(value);
17
+ const [hasReadAccess, setHasReadAccess] = useState(false);
18
+ const maxSizeInBytes = Number.isFinite(validate?.maxSizeInKB)
19
+ ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
20
+ validate.maxSizeInKB * 1000 // convert to bytes
21
+ : undefined;
22
+ const formattedMaxSize = maxSizeInBytes !== undefined ? prettyBytes(maxSizeInBytes) : '';
23
+ const { getRootProps, getInputProps, open, fileRejections } = useDropzone({
24
+ onDrop: (files) => handleUpload(files),
25
+ disabled: !!file,
26
+ accept: validate?.allowedFileExtensions
27
+ ? {
28
+ 'type/custom': validate.allowedFileExtensions.map((extension) => extension.startsWith('.') ? extension : `.${extension}`),
29
+ }
30
+ : undefined,
31
+ maxSize: maxSizeInBytes,
32
+ });
33
+ let allowedTypesMessage = '';
34
+ if (validate?.allowedFileExtensions?.length) {
35
+ if (validate.allowedFileExtensions.length === 1) {
36
+ allowedTypesMessage = `Only ${validate.allowedFileExtensions[0]} files are allowed`;
37
+ }
38
+ else {
39
+ allowedTypesMessage = `Allowed file types are: ${validate.allowedFileExtensions.slice(0, -1).join(', ')} or ${validate.allowedFileExtensions.slice(-1)[0]}`;
40
+ }
41
+ }
42
+ useEffect(() => {
43
+ setFile(value);
44
+ }, [value]);
45
+ const checkPermissions = useCallback(async () => {
46
+ if (instance) {
47
+ try {
48
+ const response = await apiServices.get(getPrefixedUrl(`/objects/${instance.objectId}/instances/${instance.id}/checkAccess`), {
49
+ params: {
50
+ action: 'read',
51
+ field: 'content',
52
+ },
53
+ });
54
+ setHasReadAccess(response.result);
55
+ }
56
+ catch (error) {
57
+ setHasReadAccess(false);
58
+ }
59
+ }
60
+ else {
61
+ setHasReadAccess(true);
62
+ }
63
+ }, [apiServices, instance]);
64
+ useEffect(() => {
65
+ checkPermissions();
66
+ }, [checkPermissions]);
67
+ const handleUpload = async (files) => {
68
+ const file = files ? files[0] : undefined;
69
+ if (file)
70
+ setFile(file);
71
+ try {
72
+ handleChange && (await handleChange(id, file));
73
+ }
74
+ catch (error) {
75
+ console.error('Failed to update field:', error);
76
+ return;
77
+ }
78
+ };
79
+ const viewFileContent = async () => {
80
+ const contentType = instance ? instance.contentType : file?.type;
81
+ let url = '';
82
+ if (file && file.size > 0) {
83
+ url = URL.createObjectURL(file);
84
+ }
85
+ else if (instance) {
86
+ try {
87
+ const contentResponse = await apiServices.get(getPrefixedUrl(`/files/${instance.id}/content`), { responseType: 'blob' });
88
+ const blob = new Blob([contentResponse], { type: contentType });
89
+ url = window.URL.createObjectURL(blob);
90
+ }
91
+ catch (error) {
92
+ setSnackbarError({
93
+ message: `Viewing the contents of ${instance.name} is currently not available.`,
94
+ type: 'error',
95
+ });
96
+ return;
97
+ }
98
+ }
99
+ if (contentType &&
100
+ [
101
+ 'application/pdf',
102
+ 'image/jpeg',
103
+ 'image/jpg',
104
+ 'image/png',
105
+ 'image/gif',
106
+ 'image/bmp',
107
+ 'image/webp',
108
+ 'text/plain',
109
+ ].includes(contentType)) {
110
+ window.open(url, '_blank');
111
+ }
112
+ else {
113
+ const link = document.createElement('a');
114
+ link.href = url;
115
+ link.setAttribute('download', file?.name ?? '');
116
+ document.body.appendChild(link);
117
+ link.click();
118
+ // Clean up and remove the link
119
+ link.parentNode?.removeChild(link);
120
+ }
121
+ };
122
+ const errors = [];
123
+ if (fileRejections.some((fileRejection) => fileRejection.errors.some((error) => error.code === 'file-invalid-type'))) {
124
+ errors.push(`Invalid file extension. ${allowedTypesMessage}`);
125
+ }
126
+ if (fileRejections.some((fileRejection) => fileRejection.errors.some((error) => error.code === 'file-too-large'))) {
127
+ errors.push(`File size exceeds the maximum limit of ${formattedMaxSize}`);
128
+ }
129
+ return (React.createElement(React.Fragment, null,
130
+ file ? (React.createElement(Grid, { container: true, sx: {
131
+ width: '100%',
132
+ border: '1px solid #C4CDD5',
133
+ borderRadius: '6px',
134
+ margin: '5px 2px',
135
+ padding: ' 8px',
136
+ display: 'flex',
137
+ alignItems: 'center',
138
+ } },
139
+ React.createElement(Grid, { item: true, sx: { display: 'flex', justifyContent: 'center', padding: '7px', marginLeft: '4px' } },
140
+ React.createElement(FileWithExtension, { fontFamily: "Arial", fileExtension: mime.getExtension(file.type && file.type !== '' ? file.type : instance?.['contentType']) ?? undefined, sx: { height: '1.5em', width: '1.5em' } })),
141
+ React.createElement(Grid, { item: true, sx: { flex: 1, justifyContent: 'center', paddingBottom: '5px' } },
142
+ React.createElement(Grid, { item: true, xs: 12 },
143
+ React.createElement(Typography, { sx: {
144
+ fontSize: '14px',
145
+ fontWeight: 700,
146
+ lineHeight: '15px',
147
+ paddingTop: '8px',
148
+ } }, file.name)),
149
+ file.size ? (React.createElement(Grid, { item: true, xs: 12 },
150
+ React.createElement(Typography, { sx: { fontSize: '12px', color: '#637381' } }, prettyBytes(file.size)))) : null),
151
+ canUpdateProperty && (React.createElement(Grid, { item: true },
152
+ React.createElement(IconButton, { "aria-label": "Delete File", sx: { padding: '3px', color: '#637381', marginRight: '10px' }, onClick: () => {
153
+ setFile(undefined);
154
+ handleChange && handleChange(id, undefined);
155
+ } },
156
+ React.createElement(TrashCan, { sx: { ':hover': { color: '#A12723' } } })))),
157
+ hasReadAccess && (React.createElement(Grid, { item: true },
158
+ React.createElement(IconButton, { "aria-label": "View File Content", sx: { padding: '3px', color: '#637381', marginRight: '16px' }, onClick: () => viewFileContent() },
159
+ React.createElement(LaunchRounded, { sx: { color: '#637381', fontSize: '22px' } })))))) : canUpdateProperty ? (React.createElement(Box, { sx: {
160
+ margin: '5px 0',
161
+ height: formattedMaxSize || allowedTypesMessage ? '115px' : '90px',
162
+ borderRadius: '8px',
163
+ display: 'flex',
164
+ justifyContent: 'center',
165
+ alignItems: 'center',
166
+ border: `1px dashed ${error ? 'red' : '#858585'}`,
167
+ position: 'relative',
168
+ cursor: 'pointer',
169
+ }, ...getRootProps(), onClick: open },
170
+ React.createElement("input", { ...getInputProps({ id }), ...(hasDescription ? { 'aria-describedby': `${id}-description` } : undefined) }),
171
+ React.createElement(Grid, { container: true, sx: { width: '100%' } },
172
+ React.createElement(Grid, { item: true, xs: 12, sx: { display: 'flex', justifyContent: 'center', paddingBottom: '7px' } },
173
+ React.createElement(UploadCloud, { sx: { color: '#919EAB', width: '50px', height: '30px' } })),
174
+ React.createElement(Grid, { item: true, xs: 12 },
175
+ React.createElement(Typography, { variant: "body2", sx: { color: '#212B36', textAlign: 'center' } },
176
+ "Drag and drop or",
177
+ ' ',
178
+ React.createElement(Typography, { component: 'span', color: 'primary', sx: { fontSize: '14px' } }, "select file"),
179
+ ' ',
180
+ "to upload"),
181
+ allowedTypesMessage && (React.createElement(Typography, { sx: {
182
+ color: '#637381',
183
+ textAlign: 'center',
184
+ fontSize: '12px',
185
+ } },
186
+ allowedTypesMessage,
187
+ ".")),
188
+ formattedMaxSize && (React.createElement(Typography, { sx: {
189
+ color: '#637381',
190
+ textAlign: 'center',
191
+ fontSize: '12px',
192
+ } }, `Maximum size is ${formattedMaxSize}.`)))))) : (React.createElement(Typography, { variant: "body2", sx: { color: '#637381' } }, "No file uploaded.")),
193
+ React.createElement(Snackbar, { open: !!snackbarError?.message, handleClose: () => setSnackbarError(null), message: snackbarError?.message, error: snackbarError?.type === 'error' }),
194
+ errors.length > 0 && (React.createElement(Box, { display: 'flex', alignItems: 'center' },
195
+ React.createElement(InfoRounded, { sx: { fontSize: '.75rem', marginRight: '3px', color: '#D3271B' } }),
196
+ React.createElement(Typography, { fontSize: '12px', color: '#D3271B', sx: { lineHeight: '18px' } }, errors.join('; ') + '.')))));
197
+ };
@@ -102,6 +102,20 @@ const InstanceLookup = (props) => {
102
102
  },
103
103
  });
104
104
  }
105
+ else if (property.type === 'fileContent') {
106
+ columns.push({
107
+ field: prop.id,
108
+ headerName: property.name,
109
+ type: 'string',
110
+ flex: 1,
111
+ valueGetter: (params) => {
112
+ const row = params.row;
113
+ // Currently the `fileContent` property type is only supported on the `sys__file` system object.
114
+ // Display the 'name' property of the `sys__file` instance.
115
+ return row['name'];
116
+ },
117
+ });
118
+ }
105
119
  else if (property.type === 'date') {
106
120
  columns.push({
107
121
  field: prop.id,
@@ -2,5 +2,6 @@ import React from 'react';
2
2
  import { FormletReference } from '@evoke-platform/context';
3
3
  declare function FormletRenderer(props: {
4
4
  entry: FormletReference;
5
+ readOnly?: boolean;
5
6
  }): React.JSX.Element;
6
7
  export default FormletRenderer;