From 95188e9938486a6769efedb1f1814e0cd4a2925c Mon Sep 17 00:00:00 2001 From: John Coburn Date: Thu, 18 Jul 2024 09:53:30 -0500 Subject: [PATCH] Multiselection - avoid calling onChange if the value if the changes are synced with the value prop (#2324) --- lib/MultiSelection/MultiSelection.js | 9 +- .../tests/MultiSelection-test.js | 936 ++++++++++-------- .../tests/MultiSelectionHarness.js | 2 + 3 files changed, 505 insertions(+), 442 deletions(-) diff --git a/lib/MultiSelection/MultiSelection.js b/lib/MultiSelection/MultiSelection.js index 3bc8e673a..feec32890 100644 --- a/lib/MultiSelection/MultiSelection.js +++ b/lib/MultiSelection/MultiSelection.js @@ -166,7 +166,12 @@ const MultiSelection = ({ boil each object down to a string, so that the strict-equals will have a better time succeeding. */ onSelectedItemsChange(changes) { - onChange(changes.selectedItems); + // only trigger onChange if selectedItems is different from from the incoming value prop. + // this avoids instances when the value can appear *not to change for the user but it's really the cause of + // values just changing between re-renders and react just catching whichever onChange was triggered last. + if (!isEqual(value, changes.selectedItems)) { + onChange(changes.selectedItems); + } awaitingChange.current = false; }, onStateChange({ selectedItems: newSelectedItems, type }) { @@ -422,7 +427,7 @@ const MultiSelection = ({ getInputProps={getInputProps} getDropdownProps={getDropdownProps} isOpen={isOpen} - placeholder={selectedItems.length === 0 && placeholder} + placeholder={selectedItems.length === 0 ? placeholder : ''} menuId={menuId} setFilterValue={setFilterValue} setFilterFocus={setFilterFocused} diff --git a/lib/MultiSelection/tests/MultiSelection-test.js b/lib/MultiSelection/tests/MultiSelection-test.js index 85b893290..e60191147 100644 --- a/lib/MultiSelection/tests/MultiSelection-test.js +++ b/lib/MultiSelection/tests/MultiSelection-test.js @@ -73,655 +73,657 @@ const listOptions = [ const testId = 'testingId'; describe('MultiSelect', () => { - const onRemove = sinon.spy(); - const onChange = sinon.spy(); - beforeEach(async () => { - await onRemove.resetHistory(); - await onChange.resetHistory(); - await mountWithContext( - - ); - }); + describe('basic rendering', () => { + const onRemove = sinon.spy(); + const onChange = sinon.spy(); + beforeEach(async () => { + await onRemove.resetHistory(); + await onChange.resetHistory(); + await mountWithContext( + + ); + }); - it('contains no axe errors - Multiselect', runAxeTest); + it('contains no axe errors - Multiselect', runAxeTest); - it('renders the control', () => multiselectionAriaLabelledby.exists()); + it('renders the control', () => multiselectionAriaLabelledby.exists()); - it('does not have a value', () => multiselectionAriaLabelledby.has({ selectedCount: 0 })); + it('does not have a value', () => multiselectionAriaLabelledby.has({ selectedCount: 0 })); - it('renders the supplied id prop', () => multiselectionAriaLabelledby.has({ id: testId })); + it('renders the supplied id prop', () => multiselectionAriaLabelledby.has({ id: testId })); - it('renders placeholder', () => multiselectionAriaLabelledby.has({ placeholder: 'test multiselect' })); + it('renders placeholder', () => multiselectionAriaLabelledby.has({ placeholder: 'test multiselect' })); - it('list is hidden by default', expectClosedMenu); + it('list is hidden by default', expectClosedMenu); - it('control\'s aria-labelledBy attribute is set', () => multiselectionAriaLabelledby.has({ ariaLabelledby: including('test-label-id') })); + it('control\'s aria-labelledBy attribute is set', () => multiselectionAriaLabelledby.has({ ariaLabelledby: including('test-label-id') })); - it('should have empty hidden value', () => hiddenInput.has({ value: '' })); + it('should have empty hidden value', () => hiddenInput.has({ value: '' })); - describe('clicking the control', () => { - beforeEach(async () => { - await multiselectionAriaLabelledby.toggle(); - }); + describe('clicking the control', () => { + beforeEach(async () => { + await multiselectionAriaLabelledby.toggle(); + }); - it('opens the list', expectOpenMenu); + it('opens the list', expectOpenMenu); - it('contains no axe errors - Multiselect: open menu', runAxeTest); + it('contains no axe errors - Multiselect: open menu', runAxeTest); - it('focuses the filter input', () => multiselection.has({ focused: true })); + it('focuses the filter input', () => multiselection.has({ focused: true })); - it(`list is rendered with ${listOptions.length} options`, () => menu.has({ optionCount: listOptions.length })); + it(`list is rendered with ${listOptions.length} options`, () => menu.has({ optionCount: listOptions.length })); - describe('clicking an option', () => { - beforeEach(async () => { - await multiselection.select('Option 2'); - }); + describe('clicking an option', () => { + beforeEach(async () => { + await multiselection.select('Option 2'); + }); - it(`sets control value to ${listOptions[2].label}`, () => ValueChipInteractor(`${listOptions[2].label}`).exists()); + it(`sets control value to ${listOptions[2].label}`, () => ValueChipInteractor(`${listOptions[2].label}`).exists()); - it('the list stays open', expectOpenMenu); + it('the list stays open', expectOpenMenu); - it('does not render placeholder', () => multiselection.has({ placeholder: '' })); + it('does not render placeholder', () => multiselection.has({ placeholder: '' })); - it('sets correct value of hidden input value', () => hiddenInput.has({ value: listOptions[2].label })); + it('sets correct value of hidden input value', () => hiddenInput.has({ value: listOptions[2].label })); - it('calls the onChange handler supplying the selected object', async () => { - await converge(() => { - if (!onChange.calledOnceWith([listOptions[2]])) throw new Error('expected onChange handler to be called with array of selected values'); + it('calls the onChange handler supplying the selected object', async () => { + await converge(() => { + if (!onChange.calledOnceWith([listOptions[2]])) throw new Error('expected onChange handler to be called with array of selected values'); + }); }); }); - }); - describe('clicking multiple options', () => { - beforeEach(async () => { - await multiselection.select([ - `${listOptions[2].label}`, - `${listOptions[3].label}`, - `${listOptions[4].label}` - ]) - }); - - it(`sets control value to ${listOptions[2].label}, ${listOptions[3].label}, ${listOptions[4].label}`, async () => { - return Promise.all( - [ - multiselection.has({ selectedCount: 3 }), - multiselection.has({ selected: [`${listOptions[2].label}`, `${listOptions[3].label}`, `${listOptions[4].label}`] }) - ] - ); - }); - - it('the list stays open', expectOpenMenu); - - describe('Keyboard: Backspace to remove values', () => { + describe('clicking multiple options', () => { beforeEach(async () => { - await multiselection.focus(); - await Keyboard.backspace(); + await multiselection.select([ + `${listOptions[2].label}`, + `${listOptions[3].label}`, + `${listOptions[4].label}` + ]) }); - it('removes the last selected item', () => { + it(`sets control value to ${listOptions[2].label}, ${listOptions[3].label}, ${listOptions[4].label}`, async () => { return Promise.all( [ - multiselection.has({ selectedCount: 2 }), - multiselection.has({ selected: [`${listOptions[2].label}`, `${listOptions[3].label}`] }) + multiselection.has({ selectedCount: 3 }), + multiselection.has({ selected: [`${listOptions[2].label}`, `${listOptions[3].label}`, `${listOptions[4].label}`] }) ] ); }); - it('calls the supplied onRemove handler, supplying the removed item.', () => { - expect(onRemove.calledOnceWith(listOptions[4])).to.equal(true); + it('the list stays open', expectOpenMenu); + + describe('Keyboard: Backspace to remove values', () => { + beforeEach(async () => { + await multiselection.focus(); + await Keyboard.backspace(); + }); + + it('removes the last selected item', () => { + return Promise.all( + [ + multiselection.has({ selectedCount: 2 }), + multiselection.has({ selected: [`${listOptions[2].label}`, `${listOptions[3].label}`] }) + ] + ); + }); + + it('calls the supplied onRemove handler, supplying the removed item.', () => { + expect(onRemove.calledOnceWith(listOptions[4])).to.equal(true); + }); }); - }); - describe('Clicking the remove button on the first value chip', () => { - beforeEach(async () => { - await ValueChipInteractor({ index: 0 }).remove(); + describe('Clicking the remove button on the first value chip', () => { + beforeEach(async () => { + await ValueChipInteractor({ index: 0 }).remove(); + }); + + it('calls the supplied onRemove handler, supplying the removed item.', () => { + expect(onRemove.calledOnceWith(listOptions[2])).to.equal(true); + }); }); - it('calls the supplied onRemove handler, supplying the removed item.', () => { - expect(onRemove.calledOnceWith(listOptions[2])).to.equal(true); + describe('Clicking the selected item in the options list', () => { + beforeEach(async () => { + await multiselection.toggle(); + await OptionInteractor(listOptions[3].label).click(); + await multiselection.has({ selectedCount: 2 }); + }); + it('calls the supplied onRemove handler, supplying the removed item.', () => { + expect(onRemove.calledOnceWith(listOptions[3])).to.equal(true); + }); + it('has option 3 selected', () => OptionInteractor(listOptions[3].label).has({ selected: false })); }); }); - describe('Clicking the selected item in the options list', () => { + describe('clicking the toggleButton with the open menu', () => { beforeEach(async () => { await multiselection.toggle(); - await OptionInteractor(listOptions[3].label).click(); - await multiselection.has({ selectedCount: 2 }); - }); - it('calls the supplied onRemove handler, supplying the removed item.', () => { - expect(onRemove.calledOnceWith(listOptions[3])).to.equal(true); }); - it('has option 3 selected', () => OptionInteractor(listOptions[3].label).has({ selected: false })); - }); - }); - describe('clicking the toggleButton with the open menu', () => { - beforeEach(async () => { - await multiselection.toggle(); + it('closes the list', expectClosedMenu); }); - it('closes the list', expectClosedMenu); - }); - - describe('filtering options', () => { - beforeEach(async () => { - await multiselection.fillIn('sample'); - }); + describe('filtering options', () => { + beforeEach(async () => { + await multiselection.fillIn('sample'); + }); - it('first option is cursored', () => OptionInteractor({ index: 0, cursored: true }).exists()); + it('first option is cursored', () => OptionInteractor({ index: 0, cursored: true }).exists()); - it('decreases list to 3 options', () => menu.has({ optionCount: 3 })); + it('decreases list to 3 options', () => menu.has({ optionCount: 3 })); - it('does not display the empty message', () => emptyMessage().absent()); + it('does not display the empty message', () => emptyMessage().absent()); - describe('clicking a filtered option', () => { - beforeEach(async () => { - await OptionInteractor({ index: 2 }).click(); - }); + describe('clicking a filtered option', () => { + beforeEach(async () => { + await OptionInteractor({ index: 2 }).click(); + }); - it('sets the value appropriately', () => { - multiselection.has({ selectedCount: 1 }); - OptionInteractor({ selected: [`${listOptions[5].label}`] }); + it('sets the value appropriately', () => { + multiselection.has({ selectedCount: 1 }); + OptionInteractor({ selected: [`${listOptions[5].label}`] }); + }); }); - }); - describe('No options available after filtering', () => { - beforeEach(async () => { - await multiselection.filter('none'); - }); + describe('No options available after filtering', () => { + beforeEach(async () => { + await multiselection.filter('none'); + }); - it('displays the empty message', () => { - emptyMessage().exists(); + it('displays the empty message', () => { + emptyMessage().exists(); + }); }); }); }); }); -}); - -describe('supplying a label prop', () => { - beforeEach(async () => { - await mountWithContext( - - ); - }); - it('renders the label', () => { - MultiSelectInteractor('test selection').exists(); - }); + describe('supplying a label prop', () => { + beforeEach(async () => { + await mountWithContext( + + ); + }); - it('control\'s aria-labelledBy attribute is set', () => { - multiselection.has({ arialabelledBy: `${testId}-label multi-value-status-${testId}` }); - }); -}); + it('renders the label', () => { + MultiSelectInteractor('test selection').exists(); + }); -describe('supplying an aria-label prop', () => { - beforeEach(async () => { - await mountWithContext( - - ); + it('control\'s aria-labelledBy attribute is set', () => { + multiselection.has({ arialabelledBy: `${testId}-label multi-value-status-${testId}` }); + }); }); - it('renders the label', () => { - MultiSelectInteractor('test aria selection').has({ visible: false }); - }); + describe('supplying an aria-label prop', () => { + beforeEach(async () => { + await mountWithContext( + + ); + }); - it('control\'s aria-labelledBy attribute is set', () => { - multiselection.has({ arialabelledBy: `${testId}-label multi-value-status-${testId}` }); - }); + it('renders the label', () => { + MultiSelectInteractor('test aria selection').has({ visible: false }); + }); - it('renders the label with an sr-only classname', () => Label('test aria selection').has({ className: including('sr-only'), visible: false })); + it('control\'s aria-labelledBy attribute is set', () => { + multiselection.has({ arialabelledBy: `${testId}-label multi-value-status-${testId}` }); + }); - it('contains no axe errors - Multiselect: aria-label prop', runAxeTest); -}); + it('renders the label with an sr-only classname', () => Label('test aria selection').has({ className: including('sr-only'), visible: false })); -describe('supplying an aria-labelledby prop', () => { - const customLabelledBy = 'custom-aria-labelledby'; - - beforeEach(async () => { - await mountWithContext( - - ); + it('contains no axe errors - Multiselect: aria-label prop', runAxeTest); }); - it('applies the aria-labelledby to the control element', () => { - multiselection.has({ ariaLabelledBy: including(customLabelledBy) }); - }); + describe('supplying an aria-labelledby prop', () => { + const customLabelledBy = 'custom-aria-labelledby'; - it('applies the aria-labelledby to the filter input', () => { - TextInput({ ariaLabelledBy: customLabelledBy }).exists(); - }); -}); + beforeEach(async () => { + await mountWithContext( + + ); + }); -describe('MultiSelection, initial value', () => { - beforeEach(async () => { - await mountWithContext( - - ); - }); + it('applies the aria-labelledby to the control element', () => { + multiselection.has({ ariaLabelledBy: including(customLabelledBy) }); + }); - it('renders the selected options\' values', () => { - multiselection.has({ selected: [listOptions[1].label, listOptions[3].label, listOptions[5].label] }); + it('applies the aria-labelledby to the filter input', () => { + TextInput({ ariaLabelledBy: customLabelledBy }).exists(); + }); }); - it('sets correct value in hidden input', () => { - hiddenInput.has({ value:`${listOptions[1].label},${listOptions[3].label},${listOptions[5].label}` }); - }); + describe('MultiSelection, initial value', () => { + beforeEach(async () => { + await mountWithContext( + + ); + }); - describe('Keyboard : navigating selected values', () => { - describe('Keyboard: pressing the Home key when middle selected value is focused', () => { - beforeEach(async () => { - await ValueChipInteractor({ index: 1 }).focus(); - await Keyboard.home(); - }); + it('renders the selected options\' values', () => { + multiselection.has({ selected: [listOptions[1].label, listOptions[3].label, listOptions[5].label] }); + }); - it('focuses the first selected item', () => { - ValueChipInteractor({ index: 0, focused: true }).exists(); - }); + it('sets correct value in hidden input', () => { + hiddenInput.has({ value:`${listOptions[1].label},${listOptions[3].label},${listOptions[5].label}` }); + }); - describe('Keyboard: pressing the End key while a selected value is focused', () => { + describe('Keyboard : navigating selected values', () => { + describe('Keyboard: pressing the Home key when middle selected value is focused', () => { beforeEach(async () => { - await Keyboard.end(); + await ValueChipInteractor({ index: 1 }).focus(); + await Keyboard.home(); }); - it('focuses the last selected item', () => { - ValueChipInteractor({ index: 2, focused: true }); + it('focuses the first selected item', () => { + ValueChipInteractor({ index: 0, focused: true }).exists(); }); - }); - }); - }); - describe('Clicking the remove button on a value chip', () => { - beforeEach(async () => { - await ValueChipInteractor({ index: 0 }).remove(); - }); + describe('Keyboard: pressing the End key while a selected value is focused', () => { + beforeEach(async () => { + await Keyboard.end(); + }); - it('removes the value from selection', () => { - multiselection.has({ selectedCount: 2 }); - }); - - it('moves focus to remaining option', () => { - ValueChipInteractor({ index: 0, focused: true }).exists(); + it('focuses the last selected item', () => { + ValueChipInteractor({ index: 2, focused: true }); + }); + }); + }); }); - describe('Clicking the remove button on the last remaining value chip', () => { + describe('Clicking the remove button on a value chip', () => { beforeEach(async () => { await ValueChipInteractor({ index: 0 }).remove(); - await ValueChipInteractor({ index: 0 }).remove(); }); it('removes the value from selection', () => { - multiselection.has({ selectedCount: 0 }); + multiselection.has({ selectedCount: 2 }); }); - it('moves focus to the filter', () => { - multiselection.is({ focused: true }); + it('moves focus to remaining option', () => { + ValueChipInteractor({ index: 0, focused: true }).exists(); }); - }); - }); - describe('Keyboard : down arrow on control with menu closed', () => { - beforeEach(async () => { - await multiselection.focus(); - await Keyboard.arrowDown(); - }); + describe('Clicking the remove button on the last remaining value chip', () => { + beforeEach(async () => { + await ValueChipInteractor({ index: 0 }).remove(); + await ValueChipInteractor({ index: 0 }).remove(); + }); - it('opens the selection menu', expectOpenMenu); + it('removes the value from selection', () => { + multiselection.has({ selectedCount: 0 }); + }); - it('the cursor is on the first option', () => { - OptionInteractor({ index: 0, cursored: true }).exists(); + it('moves focus to the filter', () => { + multiselection.is({ focused: true }); + }); + }); }); - }); - describe('Keyboard : down arrow with open menu navigates next options', () => { - beforeEach(async () => { - // back twice to keep this from passing if the down arrow test fails. - await multiselection.toggle(); - await Keyboard.arrowDown(); - await Keyboard.arrowDown(); - await Keyboard.arrowDown(); - }); + describe('Keyboard : down arrow on control with menu closed', () => { + beforeEach(async () => { + await multiselection.focus(); + await Keyboard.arrowDown(); + }); - it('moves cursor the next option', () => { - OptionInteractor({ index: 2, cursored: true }).exists(); - }); + it('opens the selection menu', expectOpenMenu); - it('sets the appropriate aria-activedescendant on the filter', () => { - TextInput({ ariaActiveDescendent: OptionInteractor({ index: 2 }).id }).exists(); + it('the cursor is on the first option', () => { + OptionInteractor({ index: 0, cursored: true }).exists(); + }); }); - describe('Keyboard : up arrow with open menu navigates to previous option', () => { + describe('Keyboard : down arrow with open menu navigates next options', () => { beforeEach(async () => { - await Keyboard.arrowUp(); - await Keyboard.arrowUp(); + // back twice to keep this from passing if the down arrow test fails. + await multiselection.toggle(); + await Keyboard.arrowDown(); + await Keyboard.arrowDown(); + await Keyboard.arrowDown(); }); - it('moves cursor the previous option', () => { - OptionInteractor({ index: 0, cursored: true }).exists(); + it('moves cursor the next option', () => { + OptionInteractor({ index: 2, cursored: true }).exists(); }); it('sets the appropriate aria-activedescendant on the filter', () => { - TextInput({ ariaActiveDescendent: OptionInteractor({ index: 0 }).id }).exists(); + TextInput({ ariaActiveDescendent: OptionInteractor({ index: 2 }).id }).exists(); }); - describe('Keyboard : pressing enter with an open menu', () => { + describe('Keyboard : up arrow with open menu navigates to previous option', () => { beforeEach(async () => { - await Keyboard.enter(); + await Keyboard.arrowUp(); + await Keyboard.arrowUp(); }); - it('selects the option at the cursor', () => { + it('moves cursor the previous option', () => { OptionInteractor({ index: 0, cursored: true }).exists(); - OptionInteractor({ index: 0, selected: true }).exists(); }); - it('adds the selection to the selected value list', () => { - ValueChipInteractor('Sample 2').exists(); + it('sets the appropriate aria-activedescendant on the filter', () => { + TextInput({ ariaActiveDescendent: OptionInteractor({ index: 0 }).id }).exists(); }); - }); - }); - describe('Keyboard: pressing End key with open menu', () => { - beforeEach(async () => { - await multiselection.toggle(); - await Keyboard.end(); - }); + describe('Keyboard : pressing enter with an open menu', () => { + beforeEach(async () => { + await Keyboard.enter(); + }); + + it('selects the option at the cursor', () => { + OptionInteractor({ index: 0, cursored: true }).exists(); + OptionInteractor({ index: 0, selected: true }).exists(); + }); - it('moves cursor to the last option', () => { - OptionInteractor({ index: 5, cursored: true }).exists(); + it('adds the selection to the selected value list', () => { + ValueChipInteractor('Sample 2').exists(); + }); + }); }); - describe('Keyboard: pressing Home key with open menu', () => { + describe('Keyboard: pressing End key with open menu', () => { beforeEach(async () => { await multiselection.toggle(); - await Keyboard.home(); + await Keyboard.end(); }); it('moves cursor to the last option', () => { - OptionInteractor({ index: 0, cursored: true }).exists(); + OptionInteractor({ index: 5, cursored: true }).exists(); }); - }); - }); - }); -}); -describe('Filtering option list: cursor on first', () => { - beforeEach(async () => { - await multiselection.filter('sam'); - }); - - it('sets cursor to first result', () => { - OptionInteractor({ index: 0, cursored: true }).exists(); - }); + describe('Keyboard: pressing Home key with open menu', () => { + beforeEach(async () => { + await multiselection.toggle(); + await Keyboard.home(); + }); - it('sets the appropriate aria-activedescendant on the filter', () => { - TextInput({ ariaActiveDescendent: OptionInteractor({ index: 0 }).id }).exists(); + it('moves cursor to the last option', () => { + OptionInteractor({ index: 0, cursored: true }).exists(); + }); + }); + }); + }); }); - describe('Keyboard control on filtered list: move cursor down', () => { + describe('Filtering option list: cursor on first', () => { beforeEach(async () => { - await Keyboard.arrowDown(); - await Keyboard.arrowDown(); + await multiselection.filter('sam'); }); - it('sets cursor to third result', () => { - // expect(selection.options(0).isCursored).to.be.false; - OptionInteractor({ index: 2, cursored: true }).exists(); + it('sets cursor to first result', () => { + OptionInteractor({ index: 0, cursored: true }).exists(); }); it('sets the appropriate aria-activedescendant on the filter', () => { - TextInput({ ariaActiveDescendent: OptionInteractor({ index: 2 }).id }).exists(); + TextInput({ ariaActiveDescendent: OptionInteractor({ index: 0 }).id }).exists(); }); - describe('Keyboard control on filtered list: move cursor up', () => { + describe('Keyboard control on filtered list: move cursor down', () => { beforeEach(async () => { - await Keyboard.arrowUp(); + await Keyboard.arrowDown(); + await Keyboard.arrowDown(); }); - it('sets cursor to second result', () => { - // expect(multiselection.options(2).isCursored).to.be.false; - OptionInteractor({ index: 1, cursored: true }).exists(); + it('sets cursor to third result', () => { + // expect(selection.options(0).isCursored).to.be.false; + OptionInteractor({ index: 2, cursored: true }).exists(); }); it('sets the appropriate aria-activedescendant on the filter', () => { - TextInput({ ariaActiveDescendent: OptionInteractor({ index: 1 }).id }).exists(); + TextInput({ ariaActiveDescendent: OptionInteractor({ index: 2 }).id }).exists(); }); - describe('Keyboard control on filtered list: pressing "Enter"', () => { + describe('Keyboard control on filtered list: move cursor up', () => { beforeEach(async () => { - await Keyboard.enter(); + await Keyboard.arrowUp(); + }); + + it('sets cursor to second result', () => { + // expect(multiselection.options(2).isCursored).to.be.false; + OptionInteractor({ index: 1, cursored: true }).exists(); }); - it('sets the cursored option as the value', () => { - multiselection.has({ selected: OptionInteractor({ index: 4 }).textContent }); + it('sets the appropriate aria-activedescendant on the filter', () => { + TextInput({ ariaActiveDescendent: OptionInteractor({ index: 1 }).id }).exists(); + }); + + describe('Keyboard control on filtered list: pressing "Enter"', () => { + beforeEach(async () => { + await Keyboard.enter(); + }); + + it('sets the cursored option as the value', () => { + multiselection.has({ selected: OptionInteractor({ index: 4 }).textContent }); + }); }); }); }); - }); - describe('Supplied an \'error\' prop', () => { - beforeEach(async () => { - await mountWithContext( - - ); - }); + describe('Supplied an \'error\' prop', () => { + beforeEach(async () => { + await mountWithContext( + + ); + }); + + it('contains no axe - Multiselect: error validation', runAxeTest); + + it('renders a validation message', () => { + multiselection.has({ error: 'Selection is invalid!' }); + }); - it('contains no axe - Multiselect: error validation', runAxeTest); + describe('With menu open', () => { + beforeEach(async () => { + await multiselection.open(); + }); - it('renders a validation message', () => { - multiselection.has({ error: 'Selection is invalid!' }); + it('renders errors in the menu', () => { + menu.has({ error: 'Selection is invalid!' }); + }); + }); }); - describe('With menu open', () => { + describe('Supplied an \'warning\' prop', () => { beforeEach(async () => { - await multiselection.open(); + await mountWithContext( + + ); }); - it('renders errors in the menu', () => { - menu.has({ error: 'Selection is invalid!' }); + it('renders a warning validation message', () => { + multiselection.has({ warning: 'You might want to choose something different!' }); + }); + + describe('With menu open', () => { + beforeEach(async () => { + await multiselection.open(); + }); + + it('renders warning in the menu', () => { + menu.has({ warning: 'You might want to choose something different!' }); + }); }); }); }); - describe('Supplied an \'warning\' prop', () => { + describe('testing actions', () => { + let actionSelected; + + const actions = [ + { + onSelect: () => { actionSelected = true; }, + render: () =>
actionItem
, + } + ]; + beforeEach(async () => { + actionSelected = false; + await mountWithContext( ); }); - it('renders a warning validation message', () => { - multiselection.has({ warning: 'You might want to choose something different!' }); + it('renders action as last option', () => { + menu.has({ optionCount: listOptions.length + 1 }); + OptionInteractor('actionItem').exists(); }); - describe('With menu open', () => { + describe('clicking an action', () => { beforeEach(async () => { - await multiselection.open(); + await multiselection.select('actionItem'); }); - it('renders warning in the menu', () => { - menu.has({ warning: 'You might want to choose something different!' }); - }); + it('calls the action\'s onSelect function', () => converge(() => { if (!actionSelected) throw new Error('MultiSelection - action should be executed'); })); }); }); -}); -describe('testing actions', () => { - let actionSelected; - - const actions = [ - { - onSelect: () => { actionSelected = true; }, - render: () =>
actionItem
, - } - ]; - - beforeEach(async () => { - actionSelected = false; - - await mountWithContext( - - ); - }); - - it('renders action as last option', () => { - menu.has({ optionCount: listOptions.length + 1 }); - OptionInteractor('actionItem').exists(); - }); + describe('asyncFiltering', () => { + let filtered; - describe('clicking an action', () => { beforeEach(async () => { - await multiselection.select('actionItem'); + filtered = false; + + await mountWithContext( + { filtered = true; }} + /> + ); }); - it('calls the action\'s onSelect function', () => converge(() => { if (!actionSelected) throw new Error('MultiSelection - action should be executed'); })); - }); -}); + describe('opening the dropdown', () => { + beforeEach(async () => { + await multiselection.open(); + }); -describe('asyncFiltering', () => { - let filtered; + it('opens the menu', expectOpenMenu); - beforeEach(async () => { - filtered = false; + it('displays loading icon (dataOptions is undefined)', () => LoadingInteractor().exists()); - await mountWithContext( - { filtered = true; }} - /> - ); + it('calls the supplied filter function', () => converge(() => filtered)); + }); }); - describe('opening the dropdown', () => { + describe('when the menu is open on a small screen', () => { beforeEach(async () => { - await multiselection.open(); + viewport.set(300); + await mountWithContext( + + ); + await multiselection.toggle(); }); - it('opens the menu', expectOpenMenu); + afterEach(() => { + viewport.reset(); + }); - it('displays loading icon (dataOptions is undefined)', () => LoadingInteractor().exists()); + it('should focus the input', () => multiselection.is({ focused: true })); - it('calls the supplied filter function', () => converge(() => filtered)); - }); -}); + describe('and the menu was closed', () => { + beforeEach(async () => { + await multiselection.toggle(); + }); -describe('when the menu is open on a small screen', () => { - beforeEach(async () => { - viewport.set(300); - await mountWithContext( - - ); - await multiselection.toggle(); + it('should focus the control', () => Button().is({ focused: true })); + }); }); - afterEach(() => { - viewport.reset(); - }); + describe('when a MultiSelect is set as required and placed inside a
', () => { + const submit = new Interactor('button[type="submit"]'); - it('should focus the input', () => multiselection.is({ focused: true })); + describe('on desktop', () => { + beforeEach(async () => { + await mountWithContext( + e.preventDefault()}> + + + + ); + await submit.click(); + }); - describe('and the menu was closed', () => { - beforeEach(async () => { - await multiselection.toggle(); + it('should focus the filter field', () => multiselection.has({ focused: true })); }); - it('should focus the control', () => Button().is({ focused: true })); - }); -}); + describe('on mobile', () => { + beforeEach(async () => { + viewport.set(300); + + await mountWithContext( +
e.preventDefault()}> + + + + ); + await submit.click(); + }); -describe('when a MultiSelect is set as required and placed inside a
', () => { - const submit = new Interactor('button[type="submit"]'); + afterEach(() => { + viewport.reset(); + }); - describe('on desktop', () => { - beforeEach(async () => { - await mountWithContext( - e.preventDefault()}> - - - - ); - await submit.click(); + it('should focus the control field', () => multiselection.is({ focused: true })); }); - it('should focus the filter field', () => multiselection.has({ focused: true })); - }); - - describe('on mobile', () => { - beforeEach(async () => { - viewport.set(300); - - await mountWithContext( -
e.preventDefault()}> + describe('when supplying a showLoading prop', () => { + beforeEach(async () => { + await mountWithContext( - - - ); - await submit.click(); - }); - - afterEach(() => { - viewport.reset(); - }); - - it('should focus the control field', () => multiselection.is({ focused: true })); - }); + ); + }); - describe('when supplying a showLoading prop', () => { - beforeEach(async () => { - await mountWithContext( - - ); + it('should display loading icon', () => LoadingInteractor().exists()); }); - - it('should display loading icon', () => LoadingInteractor().exists()); }); describe('Changing the value prop outside of render', () => { @@ -751,7 +753,7 @@ describe('when a MultiSelect is set as required and placed inside a
', () it('removes the value', () => multiselection.has({ selectedCount: 0 })) - it('calls the supplied change handler', async () => converge(() => { if (!changeSpy.calledOnceWith([])) throw new Error('expected change handler to be called') })); + it('does not call the supplied change handler', async () => converge(() => { if (changeSpy.calledOnceWith([])) throw new Error('expected change handler not to be called') })); }); }); @@ -788,4 +790,58 @@ describe('when a MultiSelect is set as required and placed inside a ', () it('calls the supplied change handler', async () => converge(() => { if (!changeSpy.calledOnceWith([listOptions[2], listOptions[0]])) throw new Error('expected change handler to be called') })); }); }); + + describe('Change handlers slow to resolve, empty initial, multiple selections...', () => { + const changeSpy = sinon.spy(); + + beforeEach(async () => { + changeSpy.resetHistory(); + const harnessFunc = (fn, val) => { + setTimeout(() => { + changeSpy(val); + fn(val); + }, 100) + }; + await mountWithContext( + + ); + }); + + it('displays no value', () => multiselection.has({ selectedCount: 0 })); + + describe('Choosing multiple values', () => { + beforeEach(async () => { + changeSpy.resetHistory(); + await multiselection.choose([ + listOptions[0].label, + listOptions[1].label, + ]); + await Button(including('update')).click(); + }); + + it('updates the value', () => multiselection.has({ selectedCount: 2 })) + + it('calls the supplied change handler twice, once per selection', async () => converge(() => { if (!changeSpy.calledTwice) throw new Error('expected change handler to be for each change') })); + + describe('deselecting both values in reverse order...', () => { + beforeEach(async () => { + changeSpy.resetHistory(); + await multiselection.choose([ + listOptions[1].label, + listOptions[0].label, + ]); + await Button(including('update')).click(); + }); + + it('updates the value', () => multiselection.has({ selectedCount: 0 })) + + it('calls the supplied change handler once per de-selection...', async () => converge(() => { if (!changeSpy.calledTwice) throw new Error('expected change handler to be for each change') })); + }); + }); + }); }); diff --git a/lib/MultiSelection/tests/MultiSelectionHarness.js b/lib/MultiSelection/tests/MultiSelectionHarness.js index 9ad1620f5..2f36c805f 100644 --- a/lib/MultiSelection/tests/MultiSelectionHarness.js +++ b/lib/MultiSelection/tests/MultiSelectionHarness.js @@ -9,10 +9,12 @@ const MultiSelectionHarness = ({ onChange = (fn, val) => { fn(val) }, }) => { const [fieldVal, setFieldVal] = useState(initValue); + const [increment, updateIncrement] = useState(0); return ( <> + ;