@evoke-platform/ui-components 1.16.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 (37) hide show
  1. package/dist/published/components/custom/CriteriaBuilder/CriteriaBuilder.js +3 -0
  2. package/dist/published/components/custom/Form/utils.d.ts +2 -2
  3. package/dist/published/components/custom/FormField/AddressFieldComponent/addressFieldComponent.js +1 -1
  4. package/dist/published/components/custom/FormField/BooleanSelect/BooleanSelect.js +15 -7
  5. package/dist/published/components/custom/FormField/InputFieldComponent/InputFieldComponent.js +1 -1
  6. package/dist/published/components/custom/FormField/Select/Select.js +1 -1
  7. package/dist/published/components/custom/FormV2/FormRenderer.d.ts +1 -0
  8. package/dist/published/components/custom/FormV2/FormRenderer.js +12 -7
  9. package/dist/published/components/custom/FormV2/FormRendererContainer.d.ts +1 -0
  10. package/dist/published/components/custom/FormV2/FormRendererContainer.js +52 -31
  11. package/dist/published/components/custom/FormV2/components/Body.d.ts +1 -0
  12. package/dist/published/components/custom/FormV2/components/Body.js +4 -2
  13. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/DropdownRepeatableFieldInput.js +3 -0
  14. package/dist/published/components/custom/FormV2/components/FormFieldTypes/CollectionFiles/RepeatableField.js +8 -6
  15. package/dist/published/components/custom/FormV2/components/FormFieldTypes/DocumentFiles/Document.js +1 -0
  16. package/dist/published/components/custom/FormV2/components/FormFieldTypes/Image.js +1 -0
  17. package/dist/published/components/custom/FormV2/components/FormFieldTypes/UserProperty.js +2 -0
  18. package/dist/published/components/custom/FormV2/components/FormletRenderer.d.ts +7 -0
  19. package/dist/published/components/custom/FormV2/components/FormletRenderer.js +22 -0
  20. package/dist/published/components/custom/FormV2/components/HtmlView.js +16 -9
  21. package/dist/published/components/custom/FormV2/components/MisconfiguredErrorMessage.d.ts +2 -0
  22. package/dist/published/components/custom/FormV2/components/MisconfiguredErrorMessage.js +15 -0
  23. package/dist/published/components/custom/FormV2/components/RecursiveEntryRenderer.js +17 -24
  24. package/dist/published/components/custom/FormV2/components/types.d.ts +2 -1
  25. package/dist/published/components/custom/FormV2/components/utils.d.ts +7 -2
  26. package/dist/published/components/custom/FormV2/components/utils.js +84 -4
  27. package/dist/published/components/custom/FormV2/tests/FormRenderer.test.js +228 -7
  28. package/dist/published/components/custom/FormV2/tests/FormRendererContainer.test.js +491 -35
  29. package/dist/published/components/custom/ViewDetailsV2/InstanceEntryRenderer.js +20 -9
  30. package/dist/published/components/custom/ViewDetailsV2/ViewDetailsV2Container.js +1 -0
  31. package/dist/published/stories/FormRenderer.stories.d.ts +3 -0
  32. package/dist/published/stories/FormRenderer.stories.js +1 -0
  33. package/dist/published/stories/FormRendererContainer.stories.d.ts +5 -0
  34. package/dist/published/stories/FormRendererData.d.ts +15 -0
  35. package/dist/published/stories/FormRendererData.js +63 -0
  36. package/dist/published/stories/sharedMswHandlers.js +4 -2
  37. package/package.json +1 -1
@@ -1,10 +1,9 @@
1
1
  import { useApiServices, useAuthenticationContext, } from '@evoke-platform/context';
2
- import { WarningRounded } from '@mui/icons-material';
2
+ import { Grid } from '@mui/material';
3
3
  import { isEmpty } from 'lodash';
4
4
  import React, { useMemo } from 'react';
5
5
  import useWidgetSize, { useFormContext } from '../../../../theme/hooks';
6
- import { Skeleton, TextField, Typography } from '../../../core';
7
- import { Box } from '../../../layout';
6
+ import { Skeleton, TextField } from '../../../core';
8
7
  import FormField from '../../FormField';
9
8
  import AccordionSections from './AccordionSections';
10
9
  import FieldWrapper from './FieldWrapper';
@@ -21,6 +20,8 @@ import FormSections from './FormSections';
21
20
  import { entryIsVisible, fetchInitialMiddleObjectInstances, fetchMiddleObject, filterEmptySections, getEntryId, getFieldDefinition, isAddressProperty, isOptionEqualToValue, updateCriteriaInputs, } from './utils';
22
21
  import HtmlView from './HtmlView';
23
22
  import { useQuery } from '@tanstack/react-query';
23
+ import FormletRenderer from './FormletRenderer';
24
+ import MisconfiguredErrorMessage from './MisconfiguredErrorMessage';
24
25
  function getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors, validation) {
25
26
  return {
26
27
  inputId: entryId,
@@ -53,8 +54,6 @@ export function RecursiveEntryRenderer(props) {
53
54
  const display = 'display' in entry ? entry.display : undefined;
54
55
  const fieldValue = entry.type === 'readonlyField' ? instance?.[entryId] : getValues ? getValues(entryId) : undefined;
55
56
  const fieldDefinition = useMemo(() => {
56
- if (!object)
57
- return undefined;
58
57
  return getFieldDefinition(entry, object, parameters);
59
58
  }, [entry, parameters, object]);
60
59
  const validation = fieldDefinition?.validation || {};
@@ -154,11 +153,15 @@ export function RecursiveEntryRenderer(props) {
154
153
  }
155
154
  }
156
155
  else if (fieldDefinition.type === 'richText') {
157
- return (React.createElement(FieldWrapper, { key: `${entryId}-${fieldValue}`, ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) }, RichTextEditor ? (React.createElement(RichTextEditor
156
+ return (React.createElement(FieldWrapper, { ...getFieldWrapperProps(fieldDefinition, entry, entryId, fieldValue, display, errors) }, RichTextEditor ? (React.createElement(RichTextEditor
158
157
  // RichTexts get a uniqueId when the form is loaded to prevent issues with multiple rich text fields on one form
159
158
  , {
160
159
  // RichTexts get a uniqueId when the form is loaded to prevent issues with multiple rich text fields on one form
161
- id: entry.uniqueId, value: fieldValue, handleUpdate: (value) => handleChange?.(entryId, value), format: "rtf", disabled: entry.type === 'readonlyField', rows: display?.rowCount, hasError: !!errors?.[entryId] })) : (React.createElement(FormField, { id: entryId, property: fieldDefinition, defaultValue: fieldValue, onChange: handleChange, onBlur: () => {
160
+ id: entry.uniqueId, value: fieldValue, handleUpdate: (value) => handleChange?.(entryId, value), onBlur: () => {
161
+ onAutosave?.(entryId)?.catch((error) => {
162
+ console.error('Autosave failed:', error);
163
+ });
164
+ }, format: "rtf", disabled: entry.type === 'readonlyField', rows: display?.rowCount, hasError: !!errors?.[entryId] })) : (React.createElement(FormField, { id: entryId, property: fieldDefinition, defaultValue: fieldValue, onChange: handleChange, onBlur: () => {
162
165
  onAutosave?.(entryId)?.catch((error) => {
163
166
  console.error('Autosave failed:', error);
164
167
  });
@@ -218,16 +221,14 @@ export function RecursiveEntryRenderer(props) {
218
221
  ? display?.booleanDisplay
219
222
  : display?.choicesDisplay?.type && display.choicesDisplay.type, label: display?.label, description: display?.description, tooltip: display?.tooltip, selectOptions: fieldDefinition?.enum, additionalProps: additionalProps, isCombobox: fieldDefinition.nonStrictEnum, strictlyTrue: fieldDefinition.strictlyTrue, protection: objectProperty?.protection })));
220
223
  }
224
+ // Forms from the FormV2 widget will get the effective form and will not need this
225
+ // but it's possible for a form passed into the FormRenderer to include a formlet type
226
+ }
227
+ else if (entry.type === 'formlet') {
228
+ return React.createElement(FormletRenderer, { entry: entry });
221
229
  }
222
230
  else if (entry.type === 'columns') {
223
- return (React.createElement(Box, { sx: {
224
- display: 'flex',
225
- alignItems: 'flex-start',
226
- gap: '30px',
227
- flexDirection: isXs ? 'column' : 'row',
228
- } }, entry.columns.map((column, colIndex) => (
229
- // calculating the width like this rather than flex={column.width} to prevent collections from being too wide
230
- React.createElement(Box, { key: colIndex, sx: { width: isXs ? '100%' : `calc(${(column.width / 12) * 100}% - 15px)` } }, column.entries?.map((columnEntry, entryIndex) => {
231
+ return (React.createElement(Grid, { container: true, columnSpacing: 4 }, entry.columns.map((column, colIndex) => (React.createElement(Grid, { key: colIndex, item: true, xs: isXs ? 12 : column.width }, column.entries?.map((columnEntry, entryIndex) => {
231
232
  return (React.createElement(RecursiveEntryRenderer, { key: entryIndex + (columnEntry?.parameterId ?? ''), entry: columnEntry }));
232
233
  }))))));
233
234
  }
@@ -236,15 +237,7 @@ export function RecursiveEntryRenderer(props) {
236
237
  return filteredEntry ? (isSmallerThanMd ? (React.createElement(AccordionSections, { entry: filteredEntry })) : (React.createElement(FormSections, { entry: filteredEntry }))) : null;
237
238
  }
238
239
  else if (!fieldDefinition) {
239
- return (React.createElement(Box, { sx: {
240
- display: 'flex',
241
- backgroundColor: '#ffc1073b',
242
- borderRadius: '8px',
243
- padding: '16.5px 14px',
244
- marginTop: '6px',
245
- } },
246
- React.createElement(WarningRounded, { sx: { paddingRight: '8px' }, color: "warning" }),
247
- React.createElement(Typography, { variant: "body2", color: "textSecondary" }, "This field was not configured correctly")));
240
+ return React.createElement(MisconfiguredErrorMessage, null);
248
241
  }
249
242
  return null;
250
243
  }
@@ -39,7 +39,8 @@ export type FileInstance = {
39
39
  export type SimpleEditorProps = {
40
40
  id: string;
41
41
  value: string;
42
- handleUpdate?: (value: string) => void;
42
+ handleUpdate?: (value: string) => void | Promise<void>;
43
+ onBlur?: () => void;
43
44
  format: 'rtf' | 'plain' | 'openxml';
44
45
  disabled?: boolean;
45
46
  rows?: number;
@@ -20,6 +20,10 @@ export declare const entryIsVisible: (entry: FormEntry, instance?: FieldValues,
20
20
  */
21
21
  export declare const getNestedParameterIds: (entry: Sections | Columns) => string[];
22
22
  export declare const getEntryId: (entry: FormEntry) => string | undefined;
23
+ /**
24
+ * Returns editable field IDs that are currently visible on the form.
25
+ */
26
+ export declare const getVisibleEditableFieldIds: (entries: FormEntry[], instance?: FieldValues, formValues?: FieldValues) => string[];
23
27
  export declare function getPrefixedUrl(url: string): string;
24
28
  export declare const isOptionEqualToValue: (option: AutocompleteOption | string, value: unknown) => boolean;
25
29
  export declare function addressProperties(addressProperty: Property): Property[];
@@ -85,7 +89,7 @@ export declare function formatSubmission(submission: FieldValues, apiServices?:
85
89
  objectId?: string;
86
90
  }, parameters?: InputParameter[]): Promise<FieldValues>;
87
91
  export declare function filterEmptySections(entry: Sections | Columns, instance?: FieldValues, formData?: FieldValues): Sections | Columns | null;
88
- export declare function assignIdsToSectionsAndRichText(entries: FormEntry[] | PanelViewEntry[], object: Obj, parameters?: InputParameter[]): FormEntry[] | PanelViewEntry[];
92
+ export declare function assignIdsToSectionsAndRichText(entries: FormEntry[] | PanelViewEntry[], object?: Obj, parameters?: InputParameter[]): FormEntry[] | PanelViewEntry[];
89
93
  /**
90
94
  * Converts a plain text string to RTF format suitable for a RichTextEditor.
91
95
  *
@@ -99,7 +103,7 @@ export declare function assignIdsToSectionsAndRichText(entries: FormEntry[] | Pa
99
103
  * This ensures that any plain text input will be safely represented in RTF without losing formatting or characters.
100
104
  */
101
105
  export declare function plainTextToRtf(plainText: string): string;
102
- export declare function getFieldDefinition(entry: FormEntry | PanelViewEntry, object: Obj, parameters?: InputParameter[]): InputParameter | Property | undefined;
106
+ export declare function getFieldDefinition(entry: FormEntry | PanelViewEntry, object?: Obj, parameters?: InputParameter[]): InputParameter | Property | undefined;
103
107
  export declare function obfuscateValue(value: unknown, property?: Partial<Property> | Partial<ObjectProperty>): unknown;
104
108
  export declare function handleFileUpload(apiServices: ApiServices, submission: FieldValues, actionId: string, objectId?: string, instanceId?: string, linkTo?: {
105
109
  instanceId: string;
@@ -123,3 +127,4 @@ export declare function useFormById(formId: string, apiServices: ApiServices, er
123
127
  export declare const extractPresetValuesFromCriteria: (criteria: Record<string, unknown>) => string[];
124
128
  export declare const extractAllCriteria: (flattenFormEntries: FormEntry[], parameters: InputParameter[]) => Record<string, unknown>[];
125
129
  export declare const extractPresetValuesFromDynamicDefaultValues: (flattenFormEntries: FormEntry[]) => string[];
130
+ export declare function convertToReadOnly(entry: FormEntry): FormEntry;
@@ -127,6 +127,48 @@ const getEntryType = (entry, parameters) => {
127
127
  return parameter?.type;
128
128
  }
129
129
  };
130
+ /**
131
+ * Returns editable field IDs that are currently visible on the form.
132
+ */
133
+ export const getVisibleEditableFieldIds = (entries, instance, formValues) => {
134
+ const fieldIds = new Set();
135
+ const collectVisibleIds = (entriesToCheck) => {
136
+ entriesToCheck.forEach((entry) => {
137
+ if (!entryIsVisible(entry, instance, formValues)) {
138
+ return;
139
+ }
140
+ if (entry.type === 'sections') {
141
+ entry.sections.forEach((section) => {
142
+ if (section.entries) {
143
+ collectVisibleIds(section.entries);
144
+ }
145
+ });
146
+ return;
147
+ }
148
+ if (entry.type === 'columns') {
149
+ entry.columns.forEach((column) => {
150
+ if (column.entries) {
151
+ collectVisibleIds(column.entries);
152
+ }
153
+ });
154
+ return;
155
+ }
156
+ if (entry.type !== 'input' && entry.type !== 'inputField') {
157
+ return;
158
+ }
159
+ // Collection entries are handled by their own flow and are not part of autosave payloads.
160
+ if (entry.type === 'inputField' && entry.input.type === 'collection') {
161
+ return;
162
+ }
163
+ const fieldId = getEntryId(entry);
164
+ if (fieldId) {
165
+ fieldIds.add(fieldId);
166
+ }
167
+ });
168
+ };
169
+ collectVisibleIds(entries ?? []);
170
+ return Array.from(fieldIds);
171
+ };
130
172
  export function getPrefixedUrl(url) {
131
173
  const wcsMatchers = ['/apps', '/pages', '/widgets'];
132
174
  const dataMatchers = [
@@ -135,8 +177,9 @@ export function getPrefixedUrl(url) {
135
177
  '/documents',
136
178
  '/payments',
137
179
  '/forms',
138
- '/locations',
139
180
  '/files',
181
+ '/formlets',
182
+ '/locations',
140
183
  ];
141
184
  const signalrMatchers = ['/hubs'];
142
185
  const accessManagementMatchers = ['/users'];
@@ -856,8 +899,8 @@ export function getFieldDefinition(entry, object, parameters) {
856
899
  }
857
900
  else if (entry.type === 'readonlyField') {
858
901
  def = isAddressProperty(entry.propertyId)
859
- ? object.properties?.find((prop) => prop.id === entry.propertyId.split('.')[0])
860
- : object.properties?.find((prop) => prop.id === entry.propertyId);
902
+ ? object?.properties?.find((prop) => prop.id === entry.propertyId.split('.')[0])
903
+ : object?.properties?.find((prop) => prop.id === entry.propertyId);
861
904
  }
862
905
  else if (entry.type === 'inputField') {
863
906
  def = entry.input;
@@ -962,7 +1005,7 @@ export function useFormById(formId, apiServices, errorMessage) {
962
1005
  queryKey: ['form', formId],
963
1006
  enabled: formId !== '_auto_' && !!formId,
964
1007
  staleTime: Infinity,
965
- queryFn: () => apiServices.get(getPrefixedUrl(`/forms/${formId}`)),
1008
+ queryFn: () => apiServices.get(getPrefixedUrl(`/forms/${formId}/effective`)),
966
1009
  meta: {
967
1010
  errorMessage,
968
1011
  },
@@ -1077,3 +1120,40 @@ export const extractPresetValuesFromDynamicDefaultValues = (flattenFormEntries)
1077
1120
  }
1078
1121
  return allPresetValues;
1079
1122
  };
1123
+ export function convertToReadOnly(entry) {
1124
+ if (entry.type === 'columns') {
1125
+ const columns = entry.columns.map((column) => ({
1126
+ ...column,
1127
+ entries: column.entries?.map((entry) => convertToReadOnly(entry)),
1128
+ }));
1129
+ return {
1130
+ ...entry,
1131
+ columns: columns,
1132
+ };
1133
+ }
1134
+ else if (entry.type === 'sections') {
1135
+ const sections = entry.sections.map((section) => ({
1136
+ ...section,
1137
+ entries: section.entries?.map((entry) => convertToReadOnly(entry)),
1138
+ }));
1139
+ return {
1140
+ ...entry,
1141
+ sections: sections,
1142
+ };
1143
+ }
1144
+ else if (entry.type === 'input') {
1145
+ return {
1146
+ type: 'readonlyField',
1147
+ propertyId: entry.parameterId,
1148
+ display: entry.display,
1149
+ };
1150
+ }
1151
+ else if (entry.type === 'inputField') {
1152
+ return {
1153
+ type: 'readonlyField',
1154
+ propertyId: entry.input.id,
1155
+ display: entry.display,
1156
+ };
1157
+ }
1158
+ return entry;
1159
+ }
@@ -8,6 +8,7 @@ import React from 'react';
8
8
  import { MemoryRouter } from 'react-router-dom';
9
9
  import { expect, it } from 'vitest';
10
10
  import FormRenderer from '../FormRenderer';
11
+ import { convertToReadOnly } from '../components/utils';
11
12
  import { accessibility508Object, createSpecialtyForm, jsonLogicDisplayTestSpecialtyForm, licenseObject, npLicense, npSpecialtyType1, npSpecialtyType2, rnLicense, rnSpecialtyType1, rnSpecialtyType2, simpleConditionDisplayTestSpecialtyForm, specialtyObject, specialtyTypeObject, UpdateAccessibilityFormOne, UpdateAccessibilityFormTwo, users, } from './test-data';
12
13
  // Mock ResizeObserver
13
14
  global.ResizeObserver = class ResizeObserver {
@@ -34,6 +35,11 @@ describe('FormRenderer', () => {
34
35
  if (sanitizedVersion === 'true') {
35
36
  return HttpResponse.json(specialtyObject);
36
37
  }
38
+ }), http.get('/api/data/objects/formletTestObject/effective', (req) => {
39
+ const sanitizedVersion = new URL(req.request.url).searchParams.get('sanitizedVersion');
40
+ if (sanitizedVersion === 'true') {
41
+ return HttpResponse.json({});
42
+ }
37
43
  }), http.get('/api/data/objects/accessibility508Object/effective', (req) => {
38
44
  const sanitizedVersion = new URL(req.request.url).searchParams.get('sanitizedVersion');
39
45
  if (sanitizedVersion === 'true') {
@@ -416,7 +422,7 @@ describe('FormRenderer', () => {
416
422
  });
417
423
  describe('when passed a regular related object entry', () => {
418
424
  const setupTestMocks = (object, form, instances) => {
419
- server.use(http.get(`/api/data/objects/${object.id}/effective`, () => HttpResponse.json(object)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)), http.get(`/api/data/forms?filter={"where":{"actionId":"${form.actionId}","objectId":"${object.id}"}}`, () => HttpResponse.json([form])), http.get(`/api/data/objects/${object.id}/instances`, () => HttpResponse.json(instances || [])));
425
+ server.use(http.get(`/api/data/objects/${object.id}/effective`, () => HttpResponse.json(object)), http.get(`/api/data/forms/${form.id}/effective`, () => HttpResponse.json(form)), http.get(`/api/data/forms?filter={"where":{"actionId":"${form.actionId}","objectId":"${object.id}"}}`, () => HttpResponse.json([form])), http.get(`/api/data/objects/${object.id}/instances`, () => HttpResponse.json(instances || [])));
420
426
  };
421
427
  describe('when in table view', () => {
422
428
  describe('when mode is existing records only', () => {
@@ -880,7 +886,7 @@ describe('FormRenderer', () => {
880
886
  });
881
887
  it('displays a not found error in record creation mode if a form could not be found', async () => {
882
888
  const user = userEvent.setup();
883
- server.use(http.get('/api/data/forms/specialtyTypeForm', () => {
889
+ server.use(http.get('/api/data/forms/specialtyTypeForm/effective', () => {
884
890
  return HttpResponse.json({ error: 'Not found' }, { status: 404 });
885
891
  }));
886
892
  render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
@@ -1180,7 +1186,7 @@ describe('FormRenderer', () => {
1180
1186
  });
1181
1187
  describe('when passed a dynamic related object entry', () => {
1182
1188
  const setupTestMocks = (object, form, instances) => {
1183
- server.use(http.get(`/api/data/objects/${object.id}/effective`, () => HttpResponse.json(object)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)), http.get(`/api/data/forms?filter={"where":{"actionId":"${form.actionId}","objectId":"${object.id}"}}`, () => HttpResponse.json([form])), http.get(`/api/data/objects/${object.id}/instances`, () => HttpResponse.json(instances || [])));
1189
+ server.use(http.get(`/api/data/objects/${object.id}/effective`, () => HttpResponse.json(object)), http.get(`/api/data/forms/${form.id}/effective`, () => HttpResponse.json(form)), http.get(`/api/data/forms?filter={"where":{"actionId":"${form.actionId}","objectId":"${object.id}"}}`, () => HttpResponse.json([form])), http.get(`/api/data/objects/${object.id}/instances`, () => HttpResponse.json(instances || [])));
1184
1190
  };
1185
1191
  const form = {
1186
1192
  id: 'relatedObjectTestForm',
@@ -1294,7 +1300,7 @@ describe('FormRenderer', () => {
1294
1300
  });
1295
1301
  it('displays a not found error in record creation mode if a form could not be found', async () => {
1296
1302
  const user = userEvent.setup();
1297
- server.use(http.get('/api/data/forms/specialtyTypeForm', () => {
1303
+ server.use(http.get('/api/data/forms/specialtyTypeForm/effective', () => {
1298
1304
  return HttpResponse.json({ error: 'Not found' }, { status: 404 });
1299
1305
  }));
1300
1306
  render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
@@ -1306,7 +1312,7 @@ describe('FormRenderer', () => {
1306
1312
  });
1307
1313
  describe('when passed a one-to-many collection entry', () => {
1308
1314
  const setupTestMocks = (object, form, instances) => {
1309
- server.use(http.get(`/api/data/objects/${object.id}/effective`, () => HttpResponse.json(object)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)), http.get(`/api/data/forms?filter={"where":{"actionId":"${form.actionId}","objectId":"${object.id}"}}`, () => HttpResponse.json([form])), http.get(`/api/data/objects/${object.id}/instances`, () => HttpResponse.json(instances || [])), http.get(`/api/data/objects/${object.id}/instances/checkAccess`, () => HttpResponse.json({
1315
+ server.use(http.get(`/api/data/objects/${object.id}/effective`, () => HttpResponse.json(object)), http.get(`/api/data/forms/${form.id}/effective`, () => HttpResponse.json(form)), http.get(`/api/data/forms?filter={"where":{"actionId":"${form.actionId}","objectId":"${object.id}"}}`, () => HttpResponse.json([form])), http.get(`/api/data/objects/${object.id}/instances`, () => HttpResponse.json(instances || [])), http.get(`/api/data/objects/${object.id}/instances/checkAccess`, () => HttpResponse.json({
1310
1316
  result: true,
1311
1317
  })));
1312
1318
  };
@@ -1325,12 +1331,16 @@ describe('FormRenderer', () => {
1325
1331
  display: {
1326
1332
  label: 'Collection',
1327
1333
  createActionId: '_create',
1334
+ updateActionId: '_update',
1328
1335
  },
1329
1336
  },
1330
1337
  ],
1331
1338
  actionId: '_update',
1332
1339
  objectId: 'testObjectForCollections',
1333
1340
  };
1341
+ let collectionObject;
1342
+ let collectionObjectForm;
1343
+ let collectionObjectUpdateForm;
1334
1344
  let scrollIntoViewMock;
1335
1345
  let originalScrollIntoView;
1336
1346
  beforeEach(() => {
@@ -1359,7 +1369,7 @@ describe('FormRenderer', () => {
1359
1369
  ],
1360
1370
  };
1361
1371
  setupTestMocks(collectionFormObject, form);
1362
- const collectionObject = {
1372
+ collectionObject = {
1363
1373
  id: 'collectionObject',
1364
1374
  name: 'Collection Object',
1365
1375
  properties: [
@@ -1396,9 +1406,23 @@ describe('FormRenderer', () => {
1396
1406
  ],
1397
1407
  defaultFormId: 'collectionObjectForm',
1398
1408
  },
1409
+ {
1410
+ id: '_update',
1411
+ name: 'Update',
1412
+ type: 'update',
1413
+ outputEvent: 'updated',
1414
+ parameters: [
1415
+ {
1416
+ id: 'name',
1417
+ name: 'Name',
1418
+ type: 'string',
1419
+ },
1420
+ ],
1421
+ defaultFormId: 'collectionObjectUpdateForm',
1422
+ },
1399
1423
  ],
1400
1424
  };
1401
- const collectionObjectForm = {
1425
+ collectionObjectForm = {
1402
1426
  id: 'collectionObjectForm',
1403
1427
  name: 'Collection Object Form',
1404
1428
  entries: [
@@ -1426,6 +1450,26 @@ describe('FormRenderer', () => {
1426
1450
  },
1427
1451
  };
1428
1452
  setupTestMocks(collectionObject, collectionObjectForm);
1453
+ collectionObjectUpdateForm = {
1454
+ id: 'collectionObjectUpdateForm',
1455
+ name: 'Collection Object Update Form',
1456
+ entries: [
1457
+ {
1458
+ type: 'input',
1459
+ parameterId: 'name',
1460
+ display: {
1461
+ label: 'Name',
1462
+ required: true,
1463
+ },
1464
+ },
1465
+ ],
1466
+ actionId: '_update',
1467
+ objectId: 'collectionObject',
1468
+ display: {
1469
+ submitLabel: 'Update Collection Item',
1470
+ },
1471
+ };
1472
+ setupTestMocks(collectionObject, collectionObjectUpdateForm);
1429
1473
  });
1430
1474
  afterEach(() => {
1431
1475
  Element.prototype.scrollIntoView = originalScrollIntoView;
@@ -1523,6 +1567,48 @@ describe('FormRenderer', () => {
1523
1567
  await screen.findByRole('columnheader', { name: 'Name' });
1524
1568
  screen.getByRole('cell', { name: 'New Collection Item' });
1525
1569
  });
1570
+ it('should not send instance metadata fields when updating a collection item', async () => {
1571
+ const user = userEvent.setup();
1572
+ const updateRequestSpy = vitest.fn();
1573
+ const parentInstance = {
1574
+ id: 'testInstanceId',
1575
+ name: 'Test Instance',
1576
+ objectId: 'testObjectForCollections',
1577
+ };
1578
+ const existingCollectionItem = {
1579
+ id: 'existingCollectionItemId',
1580
+ version: 1,
1581
+ objectId: 'collectionObject',
1582
+ name: 'Existing Collection Item',
1583
+ relatedObject: {
1584
+ id: parentInstance.id,
1585
+ name: parentInstance.name,
1586
+ },
1587
+ };
1588
+ server.use(http.get('/api/data/objects/collectionObject/instances', () => HttpResponse.json([existingCollectionItem])), http.get('/api/data/objects/collectionObject/instances/existingCollectionItemId', () => HttpResponse.json(existingCollectionItem)), http.get('/api/data/objects/collectionObject/instances/existingCollectionItemId/object', () => HttpResponse.json(collectionObject)), http.get('/api/data/objects/collectionObject/instances/existingCollectionItemId/checkAccess', () => HttpResponse.json({ result: true })), http.post('/api/data/objects/collectionObject/instances/existingCollectionItemId/actions', async ({ request }) => {
1589
+ const body = (await request.json());
1590
+ updateRequestSpy(body);
1591
+ return HttpResponse.json({
1592
+ ...existingCollectionItem,
1593
+ name: body.input.name ?? existingCollectionItem.name,
1594
+ });
1595
+ }));
1596
+ render(React.createElement(FormRenderer, { form: form, instance: parentInstance, onChange: () => { } }));
1597
+ await screen.findByRole('cell', { name: 'Existing Collection Item' });
1598
+ await user.click(await screen.findByLabelText('edit-collection-instance-0'));
1599
+ await screen.findByRole('dialog');
1600
+ const nameField = await screen.findByRole('textbox', { name: /Name */i });
1601
+ await user.clear(nameField);
1602
+ await user.type(nameField, 'Updated Collection Item');
1603
+ await user.click(screen.getByRole('button', { name: 'Update Collection Item' }));
1604
+ await waitFor(() => expect(updateRequestSpy).toHaveBeenCalled());
1605
+ const body = updateRequestSpy.mock.calls[0][0];
1606
+ expect(body.actionId).toBe('_update');
1607
+ expect(body.input).toMatchObject({ name: 'Updated Collection Item' });
1608
+ expect(body.input).not.toHaveProperty('id');
1609
+ expect(body.input).not.toHaveProperty('objectId');
1610
+ expect(body.input).not.toHaveProperty('version');
1611
+ });
1526
1612
  it('should show validation errors', async () => {
1527
1613
  const user = userEvent.setup();
1528
1614
  render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
@@ -1648,4 +1734,139 @@ describe('FormRenderer', () => {
1648
1734
  expect(textField).toHaveValue('Test Input');
1649
1735
  });
1650
1736
  });
1737
+ describe('when passed a formlet entry', () => {
1738
+ const formlet = {
1739
+ id: 'formletId',
1740
+ name: 'Test Formlet',
1741
+ entries: [
1742
+ {
1743
+ type: 'inputField',
1744
+ input: {
1745
+ type: 'date',
1746
+ id: 'dateId2',
1747
+ },
1748
+ display: {
1749
+ label: 'Date 2',
1750
+ required: false,
1751
+ },
1752
+ },
1753
+ ],
1754
+ };
1755
+ beforeEach(() => {
1756
+ server.use(http.get('/api/data/formlets/formletId', () => HttpResponse.json(formlet)));
1757
+ });
1758
+ it('should render formlet entry fields', async () => {
1759
+ const form = {
1760
+ id: 'formWithFormlet',
1761
+ name: 'Form With Formlet',
1762
+ entries: [
1763
+ {
1764
+ type: 'formlet',
1765
+ formletId: 'formletId',
1766
+ },
1767
+ ],
1768
+ actionId: '_update',
1769
+ objectId: 'formletTestObject',
1770
+ };
1771
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1772
+ await screen.findByLabelText('Date 2');
1773
+ });
1774
+ });
1775
+ describe('convertToReadOnly', () => {
1776
+ it('converts input entries to readonlyField entries', () => {
1777
+ const inputEntry = {
1778
+ type: 'input',
1779
+ parameterId: 'name',
1780
+ display: {
1781
+ label: 'Name',
1782
+ required: true,
1783
+ },
1784
+ };
1785
+ const result = convertToReadOnly(inputEntry);
1786
+ const display = inputEntry.display;
1787
+ expect(result).toEqual({
1788
+ type: 'readonlyField',
1789
+ propertyId: 'name',
1790
+ display,
1791
+ });
1792
+ });
1793
+ it('converts inputField entries to readonlyField entries', () => {
1794
+ const inputFieldEntry = {
1795
+ type: 'inputField',
1796
+ input: {
1797
+ id: 'description',
1798
+ type: 'string',
1799
+ },
1800
+ display: {
1801
+ label: 'Description',
1802
+ },
1803
+ };
1804
+ const result = convertToReadOnly(inputFieldEntry);
1805
+ const display = inputFieldEntry.display;
1806
+ expect(result).toEqual({
1807
+ type: 'readonlyField',
1808
+ propertyId: 'description',
1809
+ display,
1810
+ });
1811
+ });
1812
+ it('recursively converts nested section and column entries', () => {
1813
+ const nestedEntry = {
1814
+ type: 'sections',
1815
+ sections: [
1816
+ {
1817
+ entries: [
1818
+ {
1819
+ type: 'input',
1820
+ parameterId: 'firstName',
1821
+ display: {
1822
+ label: 'First Name',
1823
+ },
1824
+ },
1825
+ {
1826
+ type: 'columns',
1827
+ columns: [
1828
+ {
1829
+ entries: [
1830
+ {
1831
+ type: 'inputField',
1832
+ input: {
1833
+ id: 'lastName',
1834
+ type: 'string',
1835
+ },
1836
+ display: {
1837
+ label: 'Last Name',
1838
+ },
1839
+ },
1840
+ ],
1841
+ },
1842
+ ],
1843
+ },
1844
+ ],
1845
+ },
1846
+ ],
1847
+ };
1848
+ const result = convertToReadOnly(nestedEntry);
1849
+ expect(result.sections[0].entries?.[0]).toMatchObject({
1850
+ type: 'readonlyField',
1851
+ propertyId: 'firstName',
1852
+ });
1853
+ expect(result.sections[0].entries?.[1]).toMatchObject({
1854
+ type: 'columns',
1855
+ });
1856
+ const nestedColumnEntry = (result.sections[0].entries?.[1])
1857
+ .columns[0].entries?.[0];
1858
+ expect(nestedColumnEntry).toMatchObject({
1859
+ type: 'readonlyField',
1860
+ propertyId: 'lastName',
1861
+ });
1862
+ });
1863
+ it('returns unsupported entry types unchanged', () => {
1864
+ const contentEntry = {
1865
+ type: 'content',
1866
+ html: '<div>Read only content</div>',
1867
+ };
1868
+ const result = convertToReadOnly(contentEntry);
1869
+ expect(result).toBe(contentEntry);
1870
+ });
1871
+ });
1651
1872
  });