@evoke-platform/ui-components 1.10.0-dev.7 → 1.10.0-dev.8

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.
@@ -1,6 +1,6 @@
1
1
  import { useObject } from '@evoke-platform/context';
2
2
  import { isEmpty, isEqual, omit } from 'lodash';
3
- import React, { useEffect, useMemo, useState } from 'react';
3
+ import React, { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { useForm } from 'react-hook-form';
5
5
  import { useWidgetSize } from '../../../theme';
6
6
  import { Box } from '../../layout';
@@ -12,7 +12,7 @@ import { assignIdsToSectionsAndRichText, convertDocToParameters, convertProperti
12
12
  import { handleValidation } from './components/ValidationFiles/Validation';
13
13
  import ValidationErrors from './components/ValidationFiles/ValidationErrors';
14
14
  const FormRendererInternal = (props) => {
15
- const { onSubmit, onDiscardChanges, onSubmitError, value, fieldHeight, richTextEditor, form, instance, onChange, associatedObject, renderHeader, renderBody, renderFooter, } = props;
15
+ const { onSubmit, onDiscardChanges, onSubmitError: onSubmitErrorOverride, value, fieldHeight, richTextEditor, form, instance, onChange, associatedObject, renderHeader, renderBody, renderFooter, } = props;
16
16
  const { entries, name: title, objectId, actionId, display } = form;
17
17
  const { register, unregister, setValue, reset, handleSubmit, formState: { errors, isSubmitted }, getValues, } = useForm({
18
18
  defaultValues: value,
@@ -32,6 +32,7 @@ const FormRendererInternal = (props) => {
32
32
  const [isInitializing, setIsInitializing] = useState(true);
33
33
  const [parameters, setParameters] = useState();
34
34
  const objectStore = useObject(objectId);
35
+ const validationErrorsRef = useRef(null);
35
36
  const updateFetchedOptions = (newData) => {
36
37
  setFetchedOptions((prev) => ({
37
38
  ...prev,
@@ -134,9 +135,17 @@ const FormRendererInternal = (props) => {
134
135
  unregister(fieldId);
135
136
  }
136
137
  };
138
+ const onSubmitError = (errors) => {
139
+ if (onSubmitErrorOverride) {
140
+ onSubmitErrorOverride(errors);
141
+ }
142
+ else if (validationErrorsRef.current) {
143
+ validationErrorsRef.current.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
144
+ }
145
+ };
137
146
  async function unregisterHiddenFieldsAndSubmit() {
138
147
  unregisterHiddenFields(entries ?? []);
139
- await handleSubmit((data) => onSubmit && onSubmit(action?.type === 'delete' ? {} : data), (errors) => onSubmitError?.(errors))();
148
+ await handleSubmit((data) => onSubmit && onSubmit(action?.type === 'delete' ? {} : data), (errors) => onSubmitError(errors))();
140
149
  }
141
150
  const headerProps = {
142
151
  title,
@@ -148,6 +157,7 @@ const FormRendererInternal = (props) => {
148
157
  shouldShowValidationErrors: isSubmitted,
149
158
  form,
150
159
  action,
160
+ validationErrorsRef: validationErrorsRef,
151
161
  };
152
162
  const footerProps = {
153
163
  onSubmit: unregisterHiddenFieldsAndSubmit,
@@ -59,6 +59,10 @@ function FormRendererContainer(props) {
59
59
  const action = object?.actions?.find((a) => a.id === (form?.actionId || actionId));
60
60
  if (action && (instanceId || action.type === 'create')) {
61
61
  setAction(action);
62
+ // Clear error if action is found after being missing
63
+ // TODO: This entire effect should take place after form is fetched to avoid an error flickering
64
+ // That is, this effect should be merged with the one below that fetches the form
65
+ setError((prevError) => prevError === 'Action could not be found' ? undefined : prevError);
62
66
  }
63
67
  else {
64
68
  setError('Action could not be found');
@@ -99,7 +103,8 @@ function FormRendererContainer(props) {
99
103
  apiServices
100
104
  .get(getPrefixedUrl(`/forms/${formId || action?.defaultFormId}`))
101
105
  .then((evokeForm) => {
102
- if (evokeForm?.actionId === actionId) {
106
+ // If an actionId is provided, ensure it matches the form's actionId
107
+ if (!actionId || evokeForm?.actionId === actionId) {
103
108
  const form = evokeForm;
104
109
  setForm(form);
105
110
  }
@@ -6,6 +6,7 @@ import { ExpandedSection } from './types';
6
6
  export type HeaderProps = {
7
7
  hasAccordions: boolean;
8
8
  shouldShowValidationErrors: boolean;
9
+ validationErrorsRef?: React.Ref<HTMLDivElement>;
9
10
  title?: string;
10
11
  expandedSections?: ExpandedSection[];
11
12
  onExpandAll?: () => void;
@@ -6,7 +6,7 @@ import { Typography } from '../../../core/Typography';
6
6
  import Box from '../../../layout/Box/Box';
7
7
  import ValidationErrors from './ValidationFiles/ValidationErrors';
8
8
  const Header = (props) => {
9
- const { title, errors, hasAccordions, shouldShowValidationErrors, form, sx } = props;
9
+ const { title, errors, hasAccordions, shouldShowValidationErrors, validationErrorsRef, form, sx } = props;
10
10
  const { width } = useFormContext();
11
11
  const { breakpoints, isBelow } = useWidgetSize({
12
12
  scroll: false,
@@ -25,12 +25,15 @@ const Header = (props) => {
25
25
  borderBottom: !form.id ? undefined : '1px solid #e9ecef',
26
26
  gap: isSm || isXs ? 2 : 3,
27
27
  ...sx,
28
+ '.evoke-form-renderer-header': {
29
+ flex: 1,
30
+ },
28
31
  } },
29
32
  title && (React.createElement(Box, { sx: { flex: '1 1 100%' } },
30
33
  React.createElement(Title, { ...props }))),
31
34
  hasAccordions && (React.createElement(Box, { sx: { flex: '1 1 100%' } },
32
35
  React.createElement(AccordionActions, { ...props }))),
33
- shouldShowValidationErrors && !isEmpty(errors) ? React.createElement(ValidationErrors, { errors: errors }) : null));
36
+ React.createElement("div", { ref: validationErrorsRef, className: 'evoke-form-renderer-header' }, shouldShowValidationErrors && !isEmpty(errors) ? React.createElement(ValidationErrors, { errors: errors }) : null)));
34
37
  };
35
38
  // Default slot components for convenience
36
39
  export const Title = ({ title }) => (React.createElement(Typography, { sx: {
@@ -20,7 +20,7 @@ const WithProviders = ({ children }) => {
20
20
  return React.createElement(MemoryRouter, null, children);
21
21
  };
22
22
  const render = (ui, options) => baseRender(ui, { wrapper: WithProviders, ...options });
23
- describe('Form component', () => {
23
+ describe('FormRenderer', () => {
24
24
  let server;
25
25
  beforeAll(() => {
26
26
  server = setupServer(http.get('/api/data/objects/specialtyType/effective', () => HttpResponse.json(specialtyTypeObject)), http.get('/api/data/objects/specialtyType/effective', (req) => {
@@ -657,7 +657,12 @@ describe('Form component', () => {
657
657
  actionId: '_update',
658
658
  objectId: 'relatedObjectTestForm',
659
659
  };
660
+ let scrollIntoViewMock;
661
+ let originalScrollIntoView;
660
662
  beforeEach(async () => {
663
+ scrollIntoViewMock = vitest.fn();
664
+ originalScrollIntoView = Element.prototype.scrollIntoView;
665
+ Element.prototype.scrollIntoView = scrollIntoViewMock;
661
666
  const relatedObjectTestFormObject = {
662
667
  id: 'relatedObjectTestForm',
663
668
  name: 'Related Object Test Form',
@@ -694,9 +699,20 @@ describe('Form component', () => {
694
699
  type: 'content',
695
700
  html: '<div>Specialty Type Form Content</div>',
696
701
  },
702
+ {
703
+ type: 'input',
704
+ parameterId: 'requiredField',
705
+ display: {
706
+ label: 'Required Field',
707
+ required: true,
708
+ },
709
+ },
697
710
  ],
698
711
  actionId: '_create',
699
712
  objectId: 'specialtyType',
713
+ display: {
714
+ submitLabel: 'Create Specialty Type',
715
+ },
700
716
  };
701
717
  const specialtyTypeObject = {
702
718
  id: 'specialtyType',
@@ -706,7 +722,14 @@ describe('Form component', () => {
706
722
  id: '_create',
707
723
  name: 'Create',
708
724
  type: 'create',
709
- parameters: [],
725
+ parameters: [
726
+ {
727
+ id: 'requiredField',
728
+ name: 'Required Field',
729
+ type: 'string',
730
+ required: true,
731
+ },
732
+ ],
710
733
  outputEvent: 'created',
711
734
  defaultFormId: 'specialtyTypeForm',
712
735
  },
@@ -722,6 +745,9 @@ describe('Form component', () => {
722
745
  };
723
746
  setupTestMocks(specialtyTypeObject, specialtyTypeForm);
724
747
  });
748
+ afterEach(() => {
749
+ Element.prototype.scrollIntoView = originalScrollIntoView;
750
+ });
725
751
  it('displays an add button for related object fields', async () => {
726
752
  render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
727
753
  await screen.findByRole('button', { name: 'Add' });
@@ -814,6 +840,63 @@ describe('Form component', () => {
814
840
  await user.click(newRecordButton);
815
841
  await screen.findByText(/not found/i);
816
842
  });
843
+ it('should show validation errors in record creation mode', async () => {
844
+ const user = userEvent.setup();
845
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
846
+ await user.click(await screen.findByRole('button', { name: 'Add' }));
847
+ const newRecordButton = await screen.findByRole('radio', { name: /new/i });
848
+ await user.click(newRecordButton);
849
+ const createSpecialtyTypeButton = await screen.findByRole('button', {
850
+ name: /create specialty type/i,
851
+ });
852
+ await user.click(createSpecialtyTypeButton);
853
+ const errorMessage = await screen.findByRole('listitem');
854
+ expect(errorMessage).toHaveTextContent('Required Field is required');
855
+ });
856
+ it('should clear validation errors after they have been resolved', async () => {
857
+ const user = userEvent.setup();
858
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
859
+ await user.click(await screen.findByRole('button', { name: 'Add' }));
860
+ const newRecordButton = await screen.findByRole('radio', { name: /new/i });
861
+ await user.click(newRecordButton);
862
+ const createSpecialtyTypeButton = await screen.findByRole('button', {
863
+ name: /create specialty type/i,
864
+ });
865
+ await user.click(createSpecialtyTypeButton);
866
+ // Make sure error elements appear
867
+ screen.getByRole('listitem');
868
+ const requiredField = screen.getByRole('textbox', { name: /Required Field */i });
869
+ await user.type(requiredField, 'Some content here...');
870
+ expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
871
+ });
872
+ it('should scroll to validation errors after submission', async () => {
873
+ const user = userEvent.setup();
874
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
875
+ await user.click(await screen.findByRole('button', { name: 'Add' }));
876
+ const newRecordButton = await screen.findByRole('radio', { name: /new/i });
877
+ await user.click(newRecordButton);
878
+ const createSpecialtyTypeButton = await screen.findByRole('button', {
879
+ name: /create specialty type/i,
880
+ });
881
+ await user.click(createSpecialtyTypeButton);
882
+ expect(scrollIntoViewMock).toHaveBeenCalled();
883
+ });
884
+ it('should not scroll to validation errors if there are none', async () => async () => {
885
+ const user = userEvent.setup();
886
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
887
+ await user.click(await screen.findByRole('button', { name: 'Add' }));
888
+ const newRecordButton = await screen.findByRole('radio', { name: /new/i });
889
+ await user.click(newRecordButton);
890
+ // Make sure error elements appear
891
+ screen.getByRole('listitem');
892
+ const requiredField = await screen.findByRole('textbox', { name: /Required Field */i });
893
+ await user.type(requiredField, 'Some content here...');
894
+ const createSpecialtyTypeButton = await screen.findByRole('button', {
895
+ name: /create specialty type/i,
896
+ });
897
+ await user.click(createSpecialtyTypeButton);
898
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
899
+ });
817
900
  });
818
901
  });
819
902
  describe('when in dropdown view', () => {
@@ -1050,7 +1133,12 @@ describe('Form component', () => {
1050
1133
  actionId: '_update',
1051
1134
  objectId: 'testObjectForCollections',
1052
1135
  };
1136
+ let scrollIntoViewMock;
1137
+ let originalScrollIntoView;
1053
1138
  beforeEach(() => {
1139
+ scrollIntoViewMock = vitest.fn();
1140
+ originalScrollIntoView = Element.prototype.scrollIntoView;
1141
+ Element.prototype.scrollIntoView = scrollIntoViewMock;
1054
1142
  const collectionFormObject = {
1055
1143
  id: 'testObjectForCollections',
1056
1144
  name: 'Object for one-to-many collections tests',
@@ -1120,6 +1208,7 @@ describe('Form component', () => {
1120
1208
  parameterId: 'name',
1121
1209
  display: {
1122
1210
  label: 'Name',
1211
+ required: true,
1123
1212
  },
1124
1213
  },
1125
1214
  {
@@ -1133,9 +1222,15 @@ describe('Form component', () => {
1133
1222
  ],
1134
1223
  actionId: '_create',
1135
1224
  objectId: 'collectionObject',
1225
+ display: {
1226
+ submitLabel: 'Create Collection Item',
1227
+ },
1136
1228
  };
1137
1229
  setupTestMocks(collectionObject, collectionObjectForm);
1138
1230
  });
1231
+ afterEach(() => {
1232
+ Element.prototype.scrollIntoView = originalScrollIntoView;
1233
+ });
1139
1234
  it('should render collection field', async () => {
1140
1235
  render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1141
1236
  await screen.findByText('Collection');
@@ -1194,7 +1289,7 @@ describe('Form component', () => {
1194
1289
  const user = userEvent.setup();
1195
1290
  render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1196
1291
  await user.click(await screen.findByRole('button', { name: 'Add' }));
1197
- await screen.findByRole('button', { name: 'Submit' });
1292
+ await screen.findByRole('button', { name: 'Create Collection Item' });
1198
1293
  });
1199
1294
  it('should hide related object field in collection item form', async () => {
1200
1295
  const user = userEvent.setup();
@@ -1203,7 +1298,7 @@ describe('Form component', () => {
1203
1298
  await user.click(addButton);
1204
1299
  await screen.findByRole('dialog');
1205
1300
  // Make sure other form entry is present
1206
- await screen.findByRole('textbox', { name: 'Name' });
1301
+ await screen.findByRole('textbox', { name: 'Name *' });
1207
1302
  const relatedObjectField = screen.queryByRole('textbox', { name: 'Related Object' });
1208
1303
  expect(relatedObjectField).not.toBeInTheDocument();
1209
1304
  });
@@ -1222,12 +1317,136 @@ describe('Form component', () => {
1222
1317
  const addButton = await screen.findByRole('button', { name: /add/i });
1223
1318
  await user.click(addButton);
1224
1319
  await screen.findByRole('dialog');
1225
- const nameField = screen.getByRole('textbox', { name: 'Name' });
1320
+ const nameField = screen.getByRole('textbox', { name: 'Name *' });
1226
1321
  await user.type(nameField, 'New Collection Item');
1227
- const submitButton = screen.getByRole('button', { name: 'Submit' });
1322
+ const submitButton = screen.getByRole('button', { name: 'Create Collection Item' });
1228
1323
  await user.click(submitButton);
1229
1324
  await screen.findByRole('columnheader', { name: 'Name' });
1230
1325
  screen.getByRole('cell', { name: 'New Collection Item' });
1231
1326
  });
1327
+ it('should show validation errors', async () => {
1328
+ const user = userEvent.setup();
1329
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1330
+ const addButton = await screen.findByRole('button', { name: /add/i });
1331
+ await user.click(addButton);
1332
+ await screen.findByRole('dialog');
1333
+ const submitButton = screen.getByRole('button', { name: 'Create Collection Item' });
1334
+ await user.click(submitButton);
1335
+ const errorMessage = await screen.findByRole('listitem');
1336
+ expect(errorMessage).toHaveTextContent('Name is required');
1337
+ });
1338
+ it('should hide validation errors after they have been resolved', async () => {
1339
+ const user = userEvent.setup();
1340
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1341
+ const addButton = await screen.findByRole('button', { name: /add/i });
1342
+ await user.click(addButton);
1343
+ await screen.findByRole('dialog');
1344
+ const submitButton = screen.getByRole('button', { name: 'Create Collection Item' });
1345
+ await user.click(submitButton);
1346
+ // Make sure error elements appear
1347
+ screen.getByRole('listitem');
1348
+ const requiredField = screen.getByRole('textbox', { name: /Name */i });
1349
+ await user.type(requiredField, 'Some content here...');
1350
+ expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
1351
+ });
1352
+ it('should scroll to validation errors on submit', async () => {
1353
+ const user = userEvent.setup();
1354
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1355
+ const addButton = await screen.findByRole('button', { name: /add/i });
1356
+ await user.click(addButton);
1357
+ await screen.findByRole('dialog');
1358
+ const submitButton = screen.getByRole('button', { name: 'Create Collection Item' });
1359
+ await user.click(submitButton);
1360
+ expect(scrollIntoViewMock).toHaveBeenCalled();
1361
+ });
1362
+ it('should not scroll to validation errors if there are none', async () => async () => {
1363
+ const user = userEvent.setup();
1364
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1365
+ const addButton = await screen.findByRole('button', { name: /add/i });
1366
+ await user.click(addButton);
1367
+ await screen.findByRole('dialog');
1368
+ const requiredField = await screen.findByRole('textbox', { name: /Name */i });
1369
+ await user.type(requiredField, 'Some content here...');
1370
+ const submitButton = screen.getByRole('button', { name: 'Create Collection Item' });
1371
+ await user.click(submitButton);
1372
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
1373
+ });
1374
+ });
1375
+ describe('when passed a text field entry', () => {
1376
+ it('should render text field', async () => {
1377
+ const form = {
1378
+ id: 'textFieldTestForm',
1379
+ name: 'Text Field Test Form',
1380
+ entries: [
1381
+ {
1382
+ type: 'inputField',
1383
+ input: {
1384
+ id: 'textField',
1385
+ type: 'string',
1386
+ },
1387
+ display: {
1388
+ label: 'Text Field',
1389
+ },
1390
+ },
1391
+ ],
1392
+ actionId: '_update',
1393
+ objectId: 'textFieldTestObject',
1394
+ };
1395
+ const textFieldTestObject = {
1396
+ id: 'textFieldTestObject',
1397
+ name: 'Text Field Test Object',
1398
+ actions: [
1399
+ {
1400
+ id: '_update',
1401
+ name: 'Update',
1402
+ type: 'update',
1403
+ outputEvent: 'updated',
1404
+ },
1405
+ ],
1406
+ properties: [],
1407
+ };
1408
+ server.use(http.get(`/api/data/objects/${textFieldTestObject.id}/effective`, () => HttpResponse.json(textFieldTestObject)));
1409
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1410
+ await screen.findByRole('textbox', { name: 'Text Field' });
1411
+ });
1412
+ it('should allow text input in text field', async () => {
1413
+ const user = userEvent.setup();
1414
+ const form = {
1415
+ id: 'textFieldTestForm',
1416
+ name: 'Text Field Test Form',
1417
+ entries: [
1418
+ {
1419
+ type: 'inputField',
1420
+ input: {
1421
+ id: 'textField',
1422
+ type: 'string',
1423
+ },
1424
+ display: {
1425
+ label: 'Text Field',
1426
+ },
1427
+ },
1428
+ ],
1429
+ actionId: '_update',
1430
+ objectId: 'textFieldTestObject',
1431
+ };
1432
+ const textFieldTestObject = {
1433
+ id: 'textFieldTestObject',
1434
+ name: 'Text Field Test Object',
1435
+ actions: [
1436
+ {
1437
+ id: '_update',
1438
+ name: 'Update',
1439
+ type: 'update',
1440
+ outputEvent: 'updated',
1441
+ },
1442
+ ],
1443
+ properties: [],
1444
+ };
1445
+ server.use(http.get(`/api/data/objects/${textFieldTestObject.id}/effective`, () => HttpResponse.json(textFieldTestObject)));
1446
+ render(React.createElement(FormRenderer, { form: form, onChange: () => { } }));
1447
+ const textField = await screen.findByRole('textbox', { name: 'Text Field' });
1448
+ await user.type(textField, 'Test Input');
1449
+ expect(textField).toHaveValue('Test Input');
1450
+ });
1232
1451
  });
1233
1452
  });
@@ -1,5 +1,5 @@
1
1
  import * as matchers from '@testing-library/jest-dom/matchers';
2
- import { render, screen, waitFor, within } from '@testing-library/react';
2
+ import { render as baseRender, screen, waitFor, within } from '@testing-library/react';
3
3
  import userEvent from '@testing-library/user-event';
4
4
  import { isEqual } from 'lodash';
5
5
  import { http, HttpResponse } from 'msw';
@@ -16,14 +16,11 @@ global.ResizeObserver = class ResizeObserver {
16
16
  unobserve() { }
17
17
  disconnect() { }
18
18
  };
19
- const removePoppers = () => {
20
- const portalSelectors = ['.MuiAutocomplete-popper'];
21
- portalSelectors.forEach((selector) => {
22
- // eslint-disable-next-line testing-library/no-node-access
23
- document.querySelectorAll(selector).forEach((el) => el.remove());
24
- });
19
+ const WithProviders = ({ children }) => {
20
+ return React.createElement(MemoryRouter, null, children);
25
21
  };
26
- describe('Form component', () => {
22
+ const render = (ui, options) => baseRender(ui, { wrapper: WithProviders, ...options });
23
+ describe('FormRendererContainer', () => {
27
24
  let server;
28
25
  beforeAll(() => {
29
26
  server = setupServer(http.get('/api/data/objects/specialtyType/effective', () => HttpResponse.json(specialtyTypeObject)), http.get('/api/data/objects/specialtyType/effective', (req) => {
@@ -71,7 +68,6 @@ describe('Form component', () => {
71
68
  });
72
69
  afterEach(() => {
73
70
  server.resetHandlers();
74
- removePoppers();
75
71
  });
76
72
  describe('validation criteria', () => {
77
73
  it(`filters related object field with validation criteria that references a defaulted related object's nested data`, async () => {
@@ -81,13 +77,11 @@ describe('Form component', () => {
81
77
  }), http.get('/api/data/forms/specialtyForm', () => {
82
78
  return HttpResponse.json(createSpecialtyForm);
83
79
  }));
84
- render(React.createElement(MemoryRouter, null,
85
- React.createElement(FormRendererContainer, { objectId: 'specialty', formId: 'specialtyForm', dataType: 'objectInstances', actionId: '_create', associatedObject: { propertyId: 'license', instanceId: 'rnLicense' } })));
80
+ render(React.createElement(FormRendererContainer, { objectId: 'specialty', formId: 'specialtyForm', dataType: 'objectInstances', actionId: '_create', associatedObject: { propertyId: 'license', instanceId: 'rnLicense' } }));
86
81
  // Validate that the license field is hidden
87
82
  await waitFor(() => expect(screen.queryByRole('combobox', { name: 'License' })).not.toBeInTheDocument());
88
83
  // Validate that specialty type dropdown is only rendering specialty types that are associated with the selected license.
89
84
  const specialtyType = await screen.findByRole('combobox', { name: 'Specialty Type' });
90
- await new Promise((r) => setTimeout(r, 5000));
91
85
  await user.click(specialtyType);
92
86
  const openAutocomplete = await screen.findByRole('listbox');
93
87
  await within(openAutocomplete).findByRole('option', { name: 'RN Specialty Type #1' });
@@ -100,4 +94,200 @@ describe('Form component', () => {
100
94
  });
101
95
  });
102
96
  });
97
+ it('should display a submit button', async () => {
98
+ const form = {
99
+ id: 'simpleForm',
100
+ name: 'Simple Form',
101
+ entries: [],
102
+ actionId: '_create',
103
+ objectId: 'simpleObject',
104
+ };
105
+ const simpleObject = {
106
+ id: 'simpleObject',
107
+ name: 'Simple Object',
108
+ actions: [
109
+ {
110
+ id: '_create',
111
+ name: 'Create',
112
+ type: 'create',
113
+ parameters: [],
114
+ outputEvent: 'created',
115
+ },
116
+ ],
117
+ properties: [],
118
+ };
119
+ server.use(http.get(`/api/data/objects/${simpleObject.id}/effective`, () => HttpResponse.json(simpleObject)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)));
120
+ render(React.createElement(FormRendererContainer, { objectId: simpleObject.id, formId: form.id, dataType: "objectInstances" }));
121
+ await screen.findByRole('button', { name: 'Submit' });
122
+ });
123
+ it('should display a button to discard changes', async () => {
124
+ const form = {
125
+ id: 'simpleForm',
126
+ name: 'Simple Form',
127
+ entries: [],
128
+ actionId: '_create',
129
+ objectId: 'simpleObject',
130
+ };
131
+ const simpleObject = {
132
+ id: 'simpleObject',
133
+ name: 'Simple Object',
134
+ actions: [
135
+ {
136
+ id: '_create',
137
+ name: 'Create',
138
+ type: 'create',
139
+ parameters: [],
140
+ outputEvent: 'created',
141
+ },
142
+ ],
143
+ properties: [],
144
+ };
145
+ server.use(http.get(`/api/data/objects/${simpleObject.id}/effective`, () => HttpResponse.json(simpleObject)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)));
146
+ render(React.createElement(FormRendererContainer, { objectId: simpleObject.id, formId: form.id, dataType: "objectInstances" }));
147
+ await screen.findByRole('button', { name: 'Discard Changes' });
148
+ });
149
+ it('should reset the form when discarding changes', async () => {
150
+ const form = {
151
+ id: 'simpleForm2',
152
+ name: 'Simple Form',
153
+ entries: [
154
+ {
155
+ type: 'inputField',
156
+ input: {
157
+ id: 'firstName',
158
+ type: 'string',
159
+ },
160
+ display: {
161
+ label: 'First Name',
162
+ },
163
+ },
164
+ ],
165
+ actionId: '_create',
166
+ objectId: 'simpleObject2',
167
+ };
168
+ const simpleObject = {
169
+ id: 'simpleObject2',
170
+ name: 'Simple Object',
171
+ actions: [
172
+ {
173
+ id: '_create',
174
+ name: 'Create',
175
+ type: 'create',
176
+ outputEvent: 'created',
177
+ },
178
+ ],
179
+ properties: [
180
+ {
181
+ id: 'firstName',
182
+ name: 'First Name',
183
+ type: 'string',
184
+ },
185
+ ],
186
+ };
187
+ server.use(http.get(`/api/data/objects/${simpleObject.id}/effective`, () => HttpResponse.json(simpleObject)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)));
188
+ const user = userEvent.setup();
189
+ render(React.createElement(FormRendererContainer, { objectId: simpleObject.id, formId: form.id, dataType: "objectInstances" }));
190
+ const firstNameInput = await screen.findByRole('textbox', { name: 'First Name' });
191
+ await user.type(firstNameInput, 'John');
192
+ const discardButton = await screen.findByRole('button', { name: 'Discard Changes' });
193
+ await user.click(discardButton);
194
+ await waitFor(() => expect(firstNameInput).toHaveValue(''));
195
+ });
196
+ describe('when submitting a form with validation', () => {
197
+ const form = {
198
+ id: 'validationTestForm',
199
+ name: 'Validation Test Form',
200
+ entries: [
201
+ {
202
+ type: 'input',
203
+ parameterId: 'requiredField',
204
+ display: {
205
+ label: 'Required Field',
206
+ required: true,
207
+ },
208
+ },
209
+ ],
210
+ actionId: '_create',
211
+ objectId: 'validationTestObject',
212
+ };
213
+ let scrollIntoViewMock;
214
+ let originalScrollIntoView;
215
+ beforeEach(() => {
216
+ scrollIntoViewMock = vitest.fn();
217
+ originalScrollIntoView = Element.prototype.scrollIntoView;
218
+ Element.prototype.scrollIntoView = scrollIntoViewMock;
219
+ });
220
+ afterEach(() => {
221
+ Element.prototype.scrollIntoView = originalScrollIntoView;
222
+ });
223
+ const validationTestObject = {
224
+ id: 'validationTestObject',
225
+ name: 'Validation Test Object',
226
+ actions: [
227
+ {
228
+ id: '_create',
229
+ name: 'Create',
230
+ type: 'create',
231
+ parameters: [
232
+ {
233
+ id: 'requiredField',
234
+ name: 'Required Field',
235
+ type: 'string',
236
+ required: true,
237
+ },
238
+ ],
239
+ outputEvent: 'created',
240
+ },
241
+ ],
242
+ properties: [
243
+ {
244
+ id: 'requiredField',
245
+ name: 'Required Field',
246
+ type: 'string',
247
+ },
248
+ ],
249
+ };
250
+ beforeEach(() => {
251
+ server.use(http.get(`/api/data/objects/${validationTestObject.id}/effective`, () => HttpResponse.json(validationTestObject)), http.get(`/api/data/forms/${form.id}`, () => HttpResponse.json(form)));
252
+ });
253
+ it('should display validation errors after trying to submit the form', async () => {
254
+ const user = userEvent.setup();
255
+ render(React.createElement(FormRendererContainer, { objectId: validationTestObject.id, formId: form.id, dataType: "objectInstances" }));
256
+ const submitButton = await screen.findByRole('button', { name: 'Submit' });
257
+ await user.click(submitButton);
258
+ // List items are named by author, but they don't
259
+ // need to be given an accessible name here because their text content is clear enough.
260
+ // As such, we use getByRole and ensure it has the correct text
261
+ const errorMessage = await screen.findByRole('listitem');
262
+ expect(errorMessage).toHaveTextContent('Required Field is required');
263
+ });
264
+ it('should clear validation errors after they have been resolved', async () => {
265
+ const user = userEvent.setup();
266
+ render(React.createElement(FormRendererContainer, { objectId: validationTestObject.id, formId: form.id, dataType: "objectInstances" }));
267
+ const submitButton = await screen.findByRole('button', { name: 'Submit' });
268
+ await user.click(submitButton);
269
+ // Make sure error elements appear
270
+ screen.getByRole('listitem');
271
+ const requiredField = screen.getByRole('textbox', { name: /Required Field */i });
272
+ await user.type(requiredField, 'Some content here...');
273
+ expect(screen.queryByRole('listitem')).not.toBeInTheDocument();
274
+ });
275
+ it('should scroll to validation errors after submission', async () => {
276
+ const user = userEvent.setup();
277
+ render(React.createElement(FormRendererContainer, { objectId: validationTestObject.id, formId: form.id, dataType: "objectInstances" }));
278
+ const submitButton = await screen.findByRole('button', { name: 'Submit' });
279
+ await user.click(submitButton);
280
+ expect(scrollIntoViewMock).toHaveBeenCalled();
281
+ });
282
+ it('should not scroll to validation errors after submission if there are none', async () => {
283
+ const user = userEvent.setup();
284
+ server.use(http.post(`/api/data/objects/${validationTestObject.id}/instances/actions`, () => HttpResponse.json({}, { status: 200 })));
285
+ render(React.createElement(FormRendererContainer, { objectId: validationTestObject.id, formId: form.id, dataType: "objectInstances" }));
286
+ const requiredField = await screen.findByRole('textbox', { name: /Required Field */i });
287
+ await user.type(requiredField, 'Some content here...');
288
+ const submitButton = await screen.findByRole('button', { name: 'Submit' });
289
+ await user.click(submitButton);
290
+ expect(scrollIntoViewMock).not.toHaveBeenCalled();
291
+ });
292
+ });
103
293
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoke-platform/ui-components",
3
- "version": "1.10.0-dev.7",
3
+ "version": "1.10.0-dev.8",
4
4
  "description": "",
5
5
  "main": "dist/published/index.js",
6
6
  "module": "dist/published/index.js",