@evoke-platform/ui-components 1.10.1-dev.1 → 1.10.1-dev.3

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.
@@ -2,12 +2,14 @@ import { createFilterOptions, List, ListSubheader } from '@mui/material';
2
2
  import { uniq } from 'lodash';
3
3
  import React, { forwardRef, useEffect, useRef, useState } from 'react';
4
4
  import { Clear } from '../../../../icons';
5
+ import { useFormContext } from '../../../../theme/hooks';
5
6
  import { Autocomplete, FormControl, FormControlLabel, IconButton, Radio, RadioGroup, TextField, Typography, } from '../../../core';
6
7
  import { Box } from '../../../layout';
7
8
  import InputFieldComponent from '../InputFieldComponent/InputFieldComponent';
8
9
  const filter = createFilterOptions();
9
10
  const Select = (props) => {
10
11
  const { id, property, defaultValue, error, errorMessage, onBlur, onChange, readOnly, isCombobox, selectOptions, required, size, isOptionEqualToValue, renderOption, getOptionLabel, disableCloseOnSelect, additionalProps, displayOption, sortBy, } = props;
12
+ const { onAutosave } = useFormContext();
11
13
  const otherInputRef = useRef(null);
12
14
  const [isOther, setIsOther] = useState(!!isCombobox &&
13
15
  !!defaultValue &&
@@ -32,7 +34,7 @@ const Select = (props) => {
32
34
  setValue(defaultValue);
33
35
  }, [defaultValue]);
34
36
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
35
- const handleChange = (event, selected) => {
37
+ const handleChange = async (event, selected) => {
36
38
  if (Array.isArray(selected)) {
37
39
  const newValues = selected.map((option) => option.value ?? option);
38
40
  setValue(uniq(newValues));
@@ -56,6 +58,12 @@ const Select = (props) => {
56
58
  onChange && onChange(property.id, selected, property);
57
59
  }
58
60
  }
61
+ try {
62
+ await onAutosave?.(id);
63
+ }
64
+ catch (error) {
65
+ console.error('Autosave failed:', error);
66
+ }
59
67
  };
60
68
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
61
69
  const handleInputValueChange = (event, selectValue) => {
@@ -121,22 +129,29 @@ const Select = (props) => {
121
129
  }
122
130
  setIsOtherFocused(false);
123
131
  } })))))),
124
- displayOption === 'radioButton' && onChange && !readOnly && value && (React.createElement(Box, { sx: {
132
+ onChange && !readOnly && value && (React.createElement(Box, { sx: {
125
133
  ':hover': { cursor: 'pointer' },
126
134
  marginTop: '4px',
127
135
  display: 'flex',
128
136
  alignItems: 'center',
129
137
  } },
130
- React.createElement(IconButton, { "aria-label": `Clear`, onClick: () => {
138
+ React.createElement(IconButton, { "aria-label": `Clear`, onClick: async () => {
131
139
  setValue('');
132
140
  property && onChange(property.id, '');
133
141
  setIsOther(false);
142
+ // Trigger autosave immediately when clearing radio selection
143
+ try {
144
+ await onAutosave?.(id);
145
+ }
146
+ catch (error) {
147
+ console.error('Autosave failed:', error);
148
+ }
134
149
  }, sx: { padding: '3px', marginRight: '6px' } },
135
150
  React.createElement(Clear, { sx: {
136
151
  color: '#637381',
137
152
  fontSize: '1.2rem',
138
153
  } })),
139
- React.createElement(Typography, { variant: "caption" }, "Clear Selection"))))) : (React.createElement(Autocomplete, { multiple: property?.type === 'array' ? true : false, id: id, sortBy: sortBy, renderInput: (params) => (React.createElement(TextField, { ...params, value: value, fullWidth: true, onBlur: onBlur, inputProps: {
154
+ React.createElement(Typography, { variant: "caption" }, "Clear Selection"))))) : (React.createElement(Autocomplete, { multiple: property?.type === 'array', id: id, sortBy: sortBy, renderInput: (params) => (React.createElement(TextField, { ...params, value: value, fullWidth: true, onBlur: onBlur, inputProps: {
140
155
  ...params.inputProps,
141
156
  'aria-describedby': isCombobox ? `${id}-instructions` : undefined,
142
157
  } })), value: value ?? (property?.type === 'array' ? [] : undefined), onChange: handleChange, options: selectOptions ?? property?.enum ?? [], inputValue: inputValue ?? '', error: error, errorMessage: errorMessage, required: required, onInputChange: handleInputValueChange, size: size, filterOptions: (options, params) => {
@@ -169,6 +184,10 @@ const Select = (props) => {
169
184
  '& button.MuiButtonBase-root': {
170
185
  visibility: 'visible',
171
186
  },
187
+ }, slotProps: {
188
+ clearIndicator: {
189
+ 'data-testid': 'autocomplete-clear-button',
190
+ },
172
191
  }, forcePopupIcon: true, ...(isCombobox ? { selectOnFocus: true, handleHomeEndKeys: true, freeSolo: true } : {}), ...(additionalProps ?? {}) }));
173
192
  };
174
193
  export default Select;
@@ -19,8 +19,8 @@ export type FormRendererProps = BaseProps & {
19
19
  onChange: (id: string, value: unknown) => void | Promise<void>;
20
20
  onAutosave?: (fieldId: string) => void | Promise<void>;
21
21
  associatedObject?: {
22
- instanceId?: string;
23
- propertyId?: string;
22
+ instanceId: string;
23
+ propertyId: string;
24
24
  };
25
25
  renderHeader?: (props: HeaderProps) => React.ReactNode;
26
26
  renderBody?: (props: BodyProps) => React.ReactNode;
@@ -171,7 +171,13 @@ const FormRendererInternal = (props) => {
171
171
  async function unregisterHiddenFieldsAndSubmit() {
172
172
  unregisterHiddenFields(entries ?? []);
173
173
  removeUneditedProtectedValues();
174
- await handleSubmit((data) => onSubmit && onSubmit(action?.type === 'delete' ? {} : data), (errors) => onSubmitError(errors))();
174
+ await handleSubmit((data) => {
175
+ if (onSubmit) {
176
+ onSubmit(action?.type === 'delete' ? {} : data);
177
+ // clear fetched options after successful submit to allow re-evaluation with the new instance data
178
+ setFetchedOptions({});
179
+ }
180
+ }, (errors) => onSubmitError(errors))();
175
181
  }
176
182
  const headerProps = {
177
183
  title,
@@ -30,8 +30,8 @@ export type FormRendererContainerProps = BaseProps & {
30
30
  onDiscardChanges?: FormRendererProps['onDiscardChanges'];
31
31
  onSubmitError?: FormRendererProps['onSubmitError'];
32
32
  associatedObject?: {
33
- instanceId?: string;
34
- propertyId?: string;
33
+ instanceId: string;
34
+ propertyId: string;
35
35
  };
36
36
  renderContainer?: (state: FormRendererState) => React.ReactNode;
37
37
  renderHeader?: FormRendererProps['renderHeader'];
@@ -21,8 +21,8 @@ type FormContextType = {
21
21
  triggerFieldReset?: boolean;
22
22
  showSubmitError?: boolean;
23
23
  associatedObject?: {
24
- instanceId?: string;
25
- propertyId?: string;
24
+ instanceId: string;
25
+ propertyId: string;
26
26
  };
27
27
  form?: EvokeForm;
28
28
  width: number;
@@ -11,8 +11,8 @@ export type ActionDialogProps = {
11
11
  relatedParameter: InputParameter;
12
12
  relatedFormId?: string;
13
13
  associatedObject?: {
14
- instanceId?: string;
15
- propertyId?: string;
14
+ instanceId: string;
15
+ propertyId: string;
16
16
  };
17
17
  };
18
18
  export declare const ActionDialog: (props: ActionDialogProps) => React.JSX.Element;
@@ -249,8 +249,10 @@ const RepeatableField = (props) => {
249
249
  };
250
250
  }
251
251
  };
252
- const checkCreateAccess = (relatedObject) => {
253
- if (fieldDefinition.objectId && canUpdateProperty && !fetchedOptions[`${fieldDefinition.id}HasCreateAction`]) {
252
+ const checkCreateAccess = useCallback((relatedObject) => {
253
+ if (fieldDefinition.objectId &&
254
+ canUpdateProperty &&
255
+ !fetchedOptions[`${fieldDefinition.id}HasCreateAction`]) {
254
256
  apiServices
255
257
  .get(getPrefixedUrl(`/objects/${fieldDefinition.objectId}/instances/checkAccess`), {
256
258
  params: { action: 'execute', field: entry.display?.createActionId, scope: 'data' },
@@ -272,7 +274,11 @@ const RepeatableField = (props) => {
272
274
  }
273
275
  });
274
276
  }
275
- };
277
+ }, [fieldDefinition, canUpdateProperty, fetchedOptions, entry.display?.createActionId, instance, apiServices]);
278
+ useEffect(() => {
279
+ // Re-check create access when instance changes to re-evaluate the criteria
280
+ relatedObject && checkCreateAccess(relatedObject);
281
+ }, [relatedObject, checkCreateAccess]);
276
282
  useEffect(() => {
277
283
  const updatedOptions = {};
278
284
  if ((relatedInstances && !fetchedOptions[`${fieldDefinition.id}Options`]) ||
@@ -336,7 +342,9 @@ const RepeatableField = (props) => {
336
342
  ? entry.display?.updateActionId
337
343
  : entry.display?.deleteActionId));
338
344
  // when save is called we know that fieldDefinition is a parameter and fieldDefinition.objectId is defined
339
- input = await formatSubmission(input, apiServices, fieldDefinition.objectId, selectedInstanceId, action?.type === 'update' ? updateForm : undefined);
345
+ input = await formatSubmission(input, apiServices, fieldDefinition.objectId, selectedInstanceId, action?.type === 'update' ? updateForm : undefined, undefined, instance?.id && fieldDefinition.relatedPropertyId
346
+ ? { instanceId: instance.id, propertyId: fieldDefinition.relatedPropertyId }
347
+ : undefined);
340
348
  if (action?.type === 'create' && entry.display?.createActionId) {
341
349
  const updatedInput = {
342
350
  ...input,
@@ -86,7 +86,10 @@ export declare function formatSubmission(submission: FieldValues, apiServices?:
86
86
  showAlert: boolean;
87
87
  message?: string;
88
88
  isError: boolean;
89
- }>>): Promise<FieldValues>;
89
+ }>>, associatedObject?: {
90
+ instanceId: string;
91
+ propertyId: string;
92
+ }): Promise<FieldValues>;
90
93
  export declare function filterEmptySections(entry: Sections | Columns, instance?: FieldValues, formData?: FieldValues): Sections | Columns | null;
91
94
  export declare function assignIdsToSectionsAndRichText(entries: FormEntry[], object: Obj, parameters?: InputParameter[]): FormEntry[];
92
95
  /**
@@ -621,7 +621,10 @@ export const deleteDocuments = async (submittedFields, requestSuccess, apiServic
621
621
  *
622
622
  * Returns the cleaned submission ready for submitting.
623
623
  */
624
- export async function formatSubmission(submission, apiServices, objectId, instanceId, form, setSnackbarError) {
624
+ export async function formatSubmission(submission, apiServices, objectId, instanceId, form, setSnackbarError, associatedObject) {
625
+ if (associatedObject) {
626
+ delete submission[associatedObject.propertyId];
627
+ }
625
628
  const allEntries = getUnnestedEntries(form?.entries ?? []) ?? [];
626
629
  for (const [key, value] of Object.entries(submission)) {
627
630
  const entry = allEntries?.find((entry) => getEntryId(entry) === key);
@@ -326,12 +326,7 @@ describe('FormRendererContainer', () => {
326
326
  await user.clear(cityField);
327
327
  await user.type(cityField, 'Cambridge');
328
328
  await user.tab(); // Blur the field
329
- // Verify autosave was eventually called and the final call contains the updated city
330
- await waitFor(() => {
331
- expect(autosaveActionSpy).toHaveBeenCalled();
332
- });
333
- const lastCall = autosaveActionSpy.mock.lastCall?.[0];
334
- expect(lastCall).toEqual(expect.objectContaining({
329
+ expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
335
330
  input: expect.objectContaining({
336
331
  address: expect.objectContaining({
337
332
  city: 'Cambridge',
@@ -448,15 +443,7 @@ describe('FormRendererContainer', () => {
448
443
  // Wait for and select the autocomplete option
449
444
  const autocompleteOption = await screen.findByText('456 Oak Street');
450
445
  await user.click(autocompleteOption);
451
- // Verify autosave was eventually called and the final call contains the expected address values
452
- await waitFor(() => {
453
- expect(autosaveActionSpy).toHaveBeenCalled();
454
- });
455
- // The autosave is triggered twice when selecting the autocomplete option,
456
- // once by the selection and once by the onBlur event. We want to verify the last call
457
- // has the correct data.
458
- const lastCall = autosaveActionSpy.mock.lastCall?.[0];
459
- expect(lastCall).toEqual(expect.objectContaining({
446
+ expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
460
447
  input: expect.objectContaining({
461
448
  address: expect.objectContaining({
462
449
  line1: '456 Oak Street',
@@ -531,6 +518,484 @@ describe('FormRendererContainer', () => {
531
518
  // Verify autosave was not triggered
532
519
  expect(autosaveActionSpy).not.toHaveBeenCalled();
533
520
  });
521
+ it('should trigger autosave immediately when a radio button is selected', async () => {
522
+ const user = userEvent.setup();
523
+ const autosaveActionSpy = vi.fn();
524
+ // Create a form with a status field displayed as radio buttons
525
+ const licenseFormWithRadioButtons = {
526
+ id: 'licenseForm',
527
+ name: 'License Form',
528
+ objectId: 'license',
529
+ actionId: '_update',
530
+ autosaveActionId: '_autosave',
531
+ entries: [
532
+ {
533
+ parameterId: 'name',
534
+ type: 'input',
535
+ display: {
536
+ label: 'License Number',
537
+ },
538
+ },
539
+ {
540
+ parameterId: 'status',
541
+ type: 'input',
542
+ display: {
543
+ label: 'Status',
544
+ choicesDisplay: {
545
+ type: 'radioButton',
546
+ },
547
+ },
548
+ },
549
+ ],
550
+ };
551
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => {
552
+ return HttpResponse.json({
553
+ id: 'test-license',
554
+ name: 'RN-123456',
555
+ status: 'Active',
556
+ });
557
+ }), http.get('/api/data/objects/license/instances/test-license/object', () => {
558
+ return HttpResponse.json(licenseObject);
559
+ }), http.get('/api/data/forms/licenseForm', () => {
560
+ return HttpResponse.json(licenseFormWithRadioButtons);
561
+ }), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
562
+ const body = (await request.json());
563
+ autosaveActionSpy(body);
564
+ return HttpResponse.json({
565
+ id: 'test-license',
566
+ name: 'RN-123456',
567
+ status: body.input.status,
568
+ });
569
+ }));
570
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
571
+ // Wait for the form to load and the radio buttons to appear
572
+ const inactiveRadio = await screen.findByRole('radio', { name: 'Inactive' });
573
+ // Click the "Inactive" radio button
574
+ await user.click(inactiveRadio);
575
+ // Verify autosave was triggered immediately with the new status
576
+ await waitFor(() => {
577
+ expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
578
+ actionId: '_autosave',
579
+ input: expect.objectContaining({
580
+ status: 'Inactive',
581
+ }),
582
+ }));
583
+ });
584
+ });
585
+ it('should trigger autosave when the selection is cleared', async () => {
586
+ const user = userEvent.setup();
587
+ const autosaveActionSpy = vi.fn();
588
+ const licenseFormWithRadioButtons = {
589
+ id: 'licenseForm',
590
+ name: 'License Form',
591
+ objectId: 'license',
592
+ actionId: '_update',
593
+ autosaveActionId: '_autosave',
594
+ entries: [
595
+ {
596
+ parameterId: 'name',
597
+ type: 'input',
598
+ display: {
599
+ label: 'License Number',
600
+ },
601
+ },
602
+ {
603
+ parameterId: 'status',
604
+ type: 'input',
605
+ display: {
606
+ label: 'Status',
607
+ choicesDisplay: {
608
+ type: 'radioButton',
609
+ },
610
+ },
611
+ },
612
+ ],
613
+ };
614
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => {
615
+ return HttpResponse.json({
616
+ id: 'test-license',
617
+ name: 'RN-123456',
618
+ status: 'Active',
619
+ });
620
+ }), http.get('/api/data/objects/license/instances/test-license/object', () => {
621
+ return HttpResponse.json(licenseObject);
622
+ }), http.get('/api/data/forms/licenseForm', () => {
623
+ return HttpResponse.json(licenseFormWithRadioButtons);
624
+ }), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
625
+ const body = (await request.json());
626
+ autosaveActionSpy(body);
627
+ return HttpResponse.json({
628
+ id: 'test-license',
629
+ name: 'RN-123456',
630
+ status: body.input.status,
631
+ });
632
+ }));
633
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
634
+ const clearButton = await screen.findByRole('button', { name: 'Clear' });
635
+ await user.click(clearButton);
636
+ expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
637
+ actionId: '_autosave',
638
+ input: expect.objectContaining({
639
+ status: null,
640
+ }),
641
+ }));
642
+ });
643
+ it('should trigger autosave immediately when a dropdown option is selected', async () => {
644
+ const user = userEvent.setup();
645
+ const autosaveActionSpy = vi.fn();
646
+ const licenseFormWithDropdown = {
647
+ id: 'licenseForm',
648
+ name: 'License Form',
649
+ objectId: 'license',
650
+ actionId: '_update',
651
+ autosaveActionId: '_autosave',
652
+ entries: [
653
+ {
654
+ parameterId: 'name',
655
+ type: 'input',
656
+ display: { label: 'License Number' },
657
+ },
658
+ {
659
+ parameterId: 'status',
660
+ type: 'input',
661
+ display: {
662
+ label: 'Status',
663
+ choicesDisplay: { type: 'dropdown' },
664
+ },
665
+ },
666
+ ],
667
+ };
668
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: 'Active' })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithDropdown)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
669
+ const body = (await request.json());
670
+ autosaveActionSpy(body);
671
+ return HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: body.input.status });
672
+ }));
673
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
674
+ // Open the dropdown/combobox and choose an option
675
+ const combobox = await screen.findByRole('combobox', { name: 'Status' });
676
+ await user.click(combobox);
677
+ const inactiveOption = await screen.findByRole('option', { name: 'Inactive' });
678
+ await user.click(inactiveOption);
679
+ expect(autosaveActionSpy).toHaveBeenCalledWith({
680
+ actionId: '_autosave',
681
+ input: expect.objectContaining({
682
+ status: 'Inactive',
683
+ }),
684
+ });
685
+ });
686
+ it('should trigger autosave when dropdown selection is cleared', async () => {
687
+ const user = userEvent.setup();
688
+ const autosaveActionSpy = vi.fn();
689
+ const licenseFormWithDropdown = {
690
+ id: 'licenseForm',
691
+ name: 'License Form',
692
+ objectId: 'license',
693
+ actionId: '_update',
694
+ autosaveActionId: '_autosave',
695
+ entries: [
696
+ {
697
+ parameterId: 'name',
698
+ type: 'input',
699
+ display: { label: 'License Number' },
700
+ },
701
+ {
702
+ parameterId: 'status',
703
+ type: 'input',
704
+ display: {
705
+ label: 'Status',
706
+ choicesDisplay: { type: 'dropdown' },
707
+ },
708
+ },
709
+ ],
710
+ };
711
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: 'Active' })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithDropdown)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
712
+ const body = (await request.json());
713
+ autosaveActionSpy(body);
714
+ return HttpResponse.json({ id: 'test-license', name: 'RN-123456', status: body.input.status });
715
+ }));
716
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
717
+ const combobox = await screen.findByRole('combobox', { name: 'Status' });
718
+ await user.click(combobox);
719
+ // Clear button has tabindex="-1" so find it via testid
720
+ const clearButton = await screen.findByTestId('autocomplete-clear-button');
721
+ await user.click(clearButton);
722
+ expect(autosaveActionSpy).toHaveBeenCalledWith(expect.objectContaining({
723
+ actionId: '_autosave',
724
+ input: expect.objectContaining({
725
+ status: null,
726
+ }),
727
+ }));
728
+ });
729
+ it('should trigger autosave for multi-select when selecting an option', async () => {
730
+ const user = userEvent.setup();
731
+ const autosaveActionSpy = vi.fn();
732
+ const licenseFormWithMultiSelect = {
733
+ id: 'licenseForm',
734
+ name: 'License Form',
735
+ objectId: 'license',
736
+ actionId: '_update',
737
+ autosaveActionId: '_autosave',
738
+ entries: [
739
+ {
740
+ parameterId: 'name',
741
+ type: 'input',
742
+ display: { label: 'License Number' },
743
+ },
744
+ {
745
+ type: 'inputField',
746
+ input: {
747
+ id: 'categories',
748
+ type: 'array',
749
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
750
+ },
751
+ display: {
752
+ label: 'Categories',
753
+ choicesDisplay: { type: 'dropdown' },
754
+ },
755
+ },
756
+ ],
757
+ };
758
+ const licenseInstanceInitial = { id: 'test-license', name: 'RN-123456' };
759
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json(licenseInstanceInitial)), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
760
+ const body = (await request.json());
761
+ autosaveActionSpy(body);
762
+ return HttpResponse.json({
763
+ id: 'test-license',
764
+ name: body.input.name ?? 'RN-123456',
765
+ categories: body.input.categories,
766
+ });
767
+ }));
768
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
769
+ // Open the multi-select combobox and choose an additional option
770
+ const combobox = await screen.findByRole('combobox', { name: 'Categories' });
771
+ await user.click(combobox);
772
+ const plumbingOption = await screen.findByRole('option', { name: 'Electrical' });
773
+ await user.click(plumbingOption);
774
+ expect(autosaveActionSpy).toHaveBeenCalledWith({
775
+ actionId: '_autosave',
776
+ input: expect.objectContaining({ categories: ['Electrical'] }),
777
+ });
778
+ });
779
+ it('should trigger autosave for multi-select when adding an option', async () => {
780
+ const user = userEvent.setup();
781
+ const autosaveActionSpy = vi.fn();
782
+ const licenseFormWithMultiSelect = {
783
+ id: 'licenseForm',
784
+ name: 'License Form',
785
+ objectId: 'license',
786
+ actionId: '_update',
787
+ autosaveActionId: '_autosave',
788
+ entries: [
789
+ {
790
+ parameterId: 'name',
791
+ type: 'input',
792
+ display: { label: 'License Number' },
793
+ },
794
+ {
795
+ type: 'inputField',
796
+ input: {
797
+ id: 'categories',
798
+ type: 'array',
799
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
800
+ },
801
+ display: {
802
+ label: 'Categories',
803
+ choicesDisplay: { type: 'dropdown' },
804
+ },
805
+ },
806
+ ],
807
+ };
808
+ const licenseInstanceInitial = { id: 'test-license', name: 'RN-123456', categories: ['Electrical'] };
809
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json(licenseInstanceInitial)), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
810
+ const body = (await request.json());
811
+ autosaveActionSpy(body);
812
+ return HttpResponse.json({
813
+ id: 'test-license',
814
+ name: body.input.name ?? 'RN-123456',
815
+ categories: body.input.categories,
816
+ });
817
+ }));
818
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
819
+ // Open the multi-select combobox and choose an additional option
820
+ const combobox = await screen.findByRole('combobox', { name: 'Categories' });
821
+ await user.click(combobox);
822
+ const plumbingOption = await screen.findByRole('option', { name: 'Plumbing' });
823
+ await user.click(plumbingOption);
824
+ expect(autosaveActionSpy).toHaveBeenCalledWith({
825
+ actionId: '_autosave',
826
+ input: expect.objectContaining({
827
+ categories: expect.arrayContaining(['Electrical', 'Plumbing']),
828
+ }),
829
+ });
830
+ });
831
+ it('should trigger autosave when a multi-select option is removed', async () => {
832
+ const user = userEvent.setup();
833
+ const autosaveActionSpy = vi.fn();
834
+ const licenseFormWithMultiSelect = {
835
+ id: 'licenseForm',
836
+ name: 'License Form',
837
+ objectId: 'license',
838
+ actionId: '_update',
839
+ autosaveActionId: '_autosave',
840
+ entries: [
841
+ {
842
+ parameterId: 'name',
843
+ type: 'input',
844
+ display: { label: 'License Number' },
845
+ },
846
+ {
847
+ type: 'inputField',
848
+ input: {
849
+ id: 'categories',
850
+ type: 'array',
851
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
852
+ },
853
+ display: {
854
+ label: 'Categories',
855
+ choicesDisplay: { type: 'dropdown' },
856
+ },
857
+ },
858
+ ],
859
+ };
860
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({
861
+ id: 'test-license',
862
+ name: 'RN-123456',
863
+ categories: ['Electrical', 'Plumbing'],
864
+ })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
865
+ const body = (await request.json());
866
+ autosaveActionSpy(body);
867
+ return HttpResponse.json({
868
+ id: 'test-license',
869
+ name: 'RN-123456',
870
+ categories: body.input.categories,
871
+ });
872
+ }));
873
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
874
+ const electricalChipLabel = await screen.findByRole('button', { name: 'Electrical' });
875
+ // Mui autoamtically provides a test id.
876
+ const deleteIcon = await within(electricalChipLabel).findByTestId('CancelIcon');
877
+ await user.click(deleteIcon);
878
+ // Autosave should be called with only Plumbing remaining
879
+ await waitFor(() => {
880
+ expect(autosaveActionSpy).toHaveBeenCalledWith({
881
+ actionId: '_autosave',
882
+ input: expect.objectContaining({
883
+ categories: ['Plumbing'],
884
+ }),
885
+ });
886
+ });
887
+ });
888
+ it('should trigger autosave when a single multi-select option is removed via the dropdown', async () => {
889
+ const user = userEvent.setup();
890
+ const autosaveActionSpy = vi.fn();
891
+ const licenseFormWithMultiSelect = {
892
+ id: 'licenseForm',
893
+ name: 'License Form',
894
+ objectId: 'license',
895
+ actionId: '_update',
896
+ autosaveActionId: '_autosave',
897
+ entries: [
898
+ {
899
+ parameterId: 'name',
900
+ type: 'input',
901
+ display: { label: 'License Number' },
902
+ },
903
+ {
904
+ type: 'inputField',
905
+ input: {
906
+ id: 'categories',
907
+ type: 'array',
908
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
909
+ },
910
+ display: {
911
+ label: 'Categories',
912
+ choicesDisplay: { type: 'dropdown' },
913
+ },
914
+ },
915
+ ],
916
+ };
917
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({
918
+ id: 'test-license',
919
+ name: 'RN-123456',
920
+ categories: ['Electrical', 'Plumbing'],
921
+ })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
922
+ const body = (await request.json());
923
+ autosaveActionSpy(body);
924
+ return HttpResponse.json({
925
+ id: 'test-license',
926
+ name: 'RN-123456',
927
+ categories: body.input.categories,
928
+ });
929
+ }));
930
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
931
+ // Open the multi-select combobox and unselect the 'Plumbing' option
932
+ const combobox = await screen.findByRole('combobox', { name: 'Categories' });
933
+ await user.click(combobox);
934
+ // Click the option to toggle/unselect it (initially selected)
935
+ const plumbingOption = await screen.findByRole('option', { name: 'Plumbing' });
936
+ await user.click(plumbingOption);
937
+ // Expect autosave called with categories array no longer containing 'Plumbing'
938
+ expect(autosaveActionSpy).toHaveBeenCalledWith({
939
+ actionId: '_autosave',
940
+ input: expect.objectContaining({ categories: ['Electrical'] }),
941
+ });
942
+ });
943
+ it('should trigger autosave when multi-select is cleared', async () => {
944
+ const user = userEvent.setup();
945
+ const autosaveActionSpy = vi.fn();
946
+ const licenseFormWithMultiSelect = {
947
+ id: 'licenseForm',
948
+ name: 'License Form',
949
+ objectId: 'license',
950
+ actionId: '_update',
951
+ autosaveActionId: '_autosave',
952
+ entries: [
953
+ {
954
+ parameterId: 'name',
955
+ type: 'input',
956
+ display: { label: 'License Number' },
957
+ },
958
+ {
959
+ type: 'inputField',
960
+ input: {
961
+ id: 'categories',
962
+ type: 'array',
963
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
964
+ },
965
+ display: {
966
+ label: 'Categories',
967
+ choicesDisplay: { type: 'dropdown' },
968
+ },
969
+ },
970
+ ],
971
+ };
972
+ server.use(http.get('/api/data/objects/license/instances/test-license', () => HttpResponse.json({
973
+ id: 'test-license',
974
+ name: 'RN-123456',
975
+ categories: ['Electrical', 'Plumbing'],
976
+ })), http.get('/api/data/objects/license/instances/test-license/object', () => HttpResponse.json(licenseObject)), http.get('/api/data/forms/licenseForm', () => HttpResponse.json(licenseFormWithMultiSelect)), http.post('/api/data/objects/license/instances/test-license/actions', async ({ request }) => {
977
+ const body = (await request.json());
978
+ autosaveActionSpy(body);
979
+ return HttpResponse.json({
980
+ id: 'test-license',
981
+ name: 'RN-123456',
982
+ categories: body.input.categories,
983
+ });
984
+ }));
985
+ render(React.createElement(FormRendererContainer, { objectId: 'license', formId: 'licenseForm', dataType: 'objectInstances', actionId: '_update', instanceId: 'test-license' }));
986
+ // Open the multi-select combobox and click the clear button
987
+ const combobox = await screen.findByRole('combobox', { name: 'Categories' });
988
+ await user.click(combobox);
989
+ // Clear button has tabindex="-1" so find via testid
990
+ const clearButton = await screen.findByTestId('autocomplete-clear-button');
991
+ await user.click(clearButton);
992
+ expect(autosaveActionSpy).toHaveBeenCalledWith({
993
+ actionId: '_autosave',
994
+ input: expect.objectContaining({
995
+ categories: expect.arrayContaining([]),
996
+ }),
997
+ });
998
+ });
534
999
  });
535
1000
  it('should display a submit button', async () => {
536
1001
  const form = {
@@ -160,6 +160,12 @@ export const licenseObject = {
160
160
  type: 'string',
161
161
  enum: ['Active', 'Inactive'],
162
162
  },
163
+ {
164
+ id: 'categories',
165
+ name: 'Categories',
166
+ type: 'array',
167
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
168
+ },
163
169
  {
164
170
  id: 'licenseType',
165
171
  name: 'License Type',
@@ -188,6 +194,13 @@ export const licenseObject = {
188
194
  id: 'status',
189
195
  name: 'Status',
190
196
  type: 'string',
197
+ enum: ['Active', 'Inactive'],
198
+ },
199
+ {
200
+ id: 'categories',
201
+ name: 'Categories',
202
+ type: 'array',
203
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
191
204
  },
192
205
  {
193
206
  id: 'address.line1',
@@ -236,6 +249,13 @@ export const licenseObject = {
236
249
  id: 'status',
237
250
  name: 'Status',
238
251
  type: 'string',
252
+ enum: ['Active', 'Inactive'],
253
+ },
254
+ {
255
+ id: 'categories',
256
+ name: 'Categories',
257
+ type: 'array',
258
+ enum: ['Electrical', 'Plumbing', 'Mechanical', 'Other'],
239
259
  },
240
260
  {
241
261
  id: 'address.line1',
@@ -12,8 +12,8 @@ declare const _default: import("@storybook/types").ComponentAnnotations<import("
12
12
  onChange: (id: string, value: unknown) => void | Promise<void>;
13
13
  onAutosave?: ((fieldId: string) => void | Promise<void>) | undefined;
14
14
  associatedObject?: {
15
- instanceId?: string | undefined;
16
- propertyId?: string | undefined;
15
+ instanceId: string;
16
+ propertyId: string;
17
17
  } | undefined;
18
18
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
19
19
  renderBody?: ((props: import("../components/custom").BodyProps) => React.ReactNode) | undefined;
@@ -33,8 +33,8 @@ export declare const Editable: import("@storybook/types").AnnotatedStoryFn<impor
33
33
  onChange: (id: string, value: unknown) => void | Promise<void>;
34
34
  onAutosave?: ((fieldId: string) => void | Promise<void>) | undefined;
35
35
  associatedObject?: {
36
- instanceId?: string | undefined;
37
- propertyId?: string | undefined;
36
+ instanceId: string;
37
+ propertyId: string;
38
38
  } | undefined;
39
39
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
40
40
  renderBody?: ((props: import("../components/custom").BodyProps) => React.ReactNode) | undefined;
@@ -53,8 +53,8 @@ export declare const NoButtons: import("@storybook/types").AnnotatedStoryFn<impo
53
53
  onChange: (id: string, value: unknown) => void | Promise<void>;
54
54
  onAutosave?: ((fieldId: string) => void | Promise<void>) | undefined;
55
55
  associatedObject?: {
56
- instanceId?: string | undefined;
57
- propertyId?: string | undefined;
56
+ instanceId: string;
57
+ propertyId: string;
58
58
  } | undefined;
59
59
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
60
60
  renderBody?: ((props: import("../components/custom").BodyProps) => React.ReactNode) | undefined;
@@ -73,8 +73,8 @@ export declare const DocumentForm: import("@storybook/types").AnnotatedStoryFn<i
73
73
  onChange: (id: string, value: unknown) => void | Promise<void>;
74
74
  onAutosave?: ((fieldId: string) => void | Promise<void>) | undefined;
75
75
  associatedObject?: {
76
- instanceId?: string | undefined;
77
- propertyId?: string | undefined;
76
+ instanceId: string;
77
+ propertyId: string;
78
78
  } | undefined;
79
79
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
80
80
  renderBody?: ((props: import("../components/custom").BodyProps) => React.ReactNode) | undefined;
@@ -16,8 +16,8 @@ declare const _default: import("@storybook/types").ComponentAnnotations<import("
16
16
  onDiscardChanges?: (() => void) | undefined;
17
17
  onSubmitError?: import("react-hook-form").SubmitErrorHandler<import("react-hook-form").FieldValues> | undefined;
18
18
  associatedObject?: {
19
- instanceId?: string | undefined;
20
- propertyId?: string | undefined;
19
+ instanceId: string;
20
+ propertyId: string;
21
21
  } | undefined;
22
22
  renderContainer?: ((state: import("../components/custom/FormV2/FormRendererContainer").FormRendererState) => React.ReactNode) | undefined;
23
23
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
@@ -43,8 +43,8 @@ export declare const Editable: import("@storybook/types").AnnotatedStoryFn<impor
43
43
  onDiscardChanges?: (() => void) | undefined;
44
44
  onSubmitError?: import("react-hook-form").SubmitErrorHandler<import("react-hook-form").FieldValues> | undefined;
45
45
  associatedObject?: {
46
- instanceId?: string | undefined;
47
- propertyId?: string | undefined;
46
+ instanceId: string;
47
+ propertyId: string;
48
48
  } | undefined;
49
49
  renderContainer?: ((state: import("../components/custom/FormV2/FormRendererContainer").FormRendererState) => React.ReactNode) | undefined;
50
50
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
@@ -69,8 +69,8 @@ export declare const DefaultForm: import("@storybook/types").AnnotatedStoryFn<im
69
69
  onDiscardChanges?: (() => void) | undefined;
70
70
  onSubmitError?: import("react-hook-form").SubmitErrorHandler<import("react-hook-form").FieldValues> | undefined;
71
71
  associatedObject?: {
72
- instanceId?: string | undefined;
73
- propertyId?: string | undefined;
72
+ instanceId: string;
73
+ propertyId: string;
74
74
  } | undefined;
75
75
  renderContainer?: ((state: import("../components/custom/FormV2/FormRendererContainer").FormRendererState) => React.ReactNode) | undefined;
76
76
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
@@ -95,8 +95,8 @@ export declare const NoButtons: import("@storybook/types").AnnotatedStoryFn<impo
95
95
  onDiscardChanges?: (() => void) | undefined;
96
96
  onSubmitError?: import("react-hook-form").SubmitErrorHandler<import("react-hook-form").FieldValues> | undefined;
97
97
  associatedObject?: {
98
- instanceId?: string | undefined;
99
- propertyId?: string | undefined;
98
+ instanceId: string;
99
+ propertyId: string;
100
100
  } | undefined;
101
101
  renderContainer?: ((state: import("../components/custom/FormV2/FormRendererContainer").FormRendererState) => React.ReactNode) | undefined;
102
102
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
@@ -121,8 +121,8 @@ export declare const DocumentForm: import("@storybook/types").AnnotatedStoryFn<i
121
121
  onDiscardChanges?: (() => void) | undefined;
122
122
  onSubmitError?: import("react-hook-form").SubmitErrorHandler<import("react-hook-form").FieldValues> | undefined;
123
123
  associatedObject?: {
124
- instanceId?: string | undefined;
125
- propertyId?: string | undefined;
124
+ instanceId: string;
125
+ propertyId: string;
126
126
  } | undefined;
127
127
  renderContainer?: ((state: import("../components/custom/FormV2/FormRendererContainer").FormRendererState) => React.ReactNode) | undefined;
128
128
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
@@ -147,8 +147,8 @@ export declare const FormWithSections: import("@storybook/types").AnnotatedStory
147
147
  onDiscardChanges?: (() => void) | undefined;
148
148
  onSubmitError?: import("react-hook-form").SubmitErrorHandler<import("react-hook-form").FieldValues> | undefined;
149
149
  associatedObject?: {
150
- instanceId?: string | undefined;
151
- propertyId?: string | undefined;
150
+ instanceId: string;
151
+ propertyId: string;
152
152
  } | undefined;
153
153
  renderContainer?: ((state: import("../components/custom/FormV2/FormRendererContainer").FormRendererState) => React.ReactNode) | undefined;
154
154
  renderHeader?: ((props: import("../components/custom").HeaderProps) => React.ReactNode) | undefined;
@@ -153,9 +153,9 @@ export declare function useFormContext(): {
153
153
  triggerFieldReset?: boolean | undefined;
154
154
  showSubmitError?: boolean | undefined;
155
155
  associatedObject?: {
156
- instanceId?: string | undefined;
157
- propertyId?: string | undefined; /** Extra large screens (1536px and up) */
158
- } | undefined;
156
+ instanceId: string;
157
+ propertyId: string; /** Extra large screens (1536px and up) */
158
+ } | undefined; /** Extra large screens (1536px and up) */
159
159
  form?: import("@evoke-platform/context").EvokeForm | undefined;
160
160
  width: number;
161
161
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoke-platform/ui-components",
3
- "version": "1.10.1-dev.1",
3
+ "version": "1.10.1-dev.3",
4
4
  "description": "",
5
5
  "main": "dist/published/index.js",
6
6
  "module": "dist/published/index.js",