From 648c86c612e9c7ca7bd2ec74aa6c5c56a4ab9d73 Mon Sep 17 00:00:00 2001 From: Suzanne Aitchison Date: Fri, 29 Apr 2022 09:39:25 +0100 Subject: [PATCH] Add prop for user-defined selections in MultiSelectAutocomplete (#17402) * add new prop and update specs * update storybook --- .../article-form/components/TagsField.jsx | 1 + .../MultiSelectAutocomplete.jsx | 38 +++- .../__stories__/MultiSelectAutocomplete.mdx | 8 +- .../MultiSelectAutocomplete.stories.jsx | 7 + .../MultiSelectAutocomplete.test.jsx | 166 +++++++++++++----- .../listings/components/ListingTagsField.jsx | 1 + 6 files changed, 171 insertions(+), 50 deletions(-) diff --git a/app/javascript/article-form/components/TagsField.jsx b/app/javascript/article-form/components/TagsField.jsx index 43f83c0ee..3f3c173b6 100644 --- a/app/javascript/article-form/components/TagsField.jsx +++ b/app/javascript/article-form/components/TagsField.jsx @@ -44,6 +44,7 @@ export const TagsField = ({ onInput, defaultValue, switchHelpContext }) => { onSelectionsChanged={syncSelections} onFocus={switchHelpContext} inputId="tag-input" + allowUserDefinedSelections={true} /> ); }; diff --git a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx index 9705bc063..ef65f72b6 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx +++ b/app/javascript/crayons/MultiSelectAutocomplete/MultiSelectAutocomplete.jsx @@ -66,6 +66,7 @@ const reducer = (state, action) => { * @param {number} props.maxSelections Optional maximum number of allowed selections * @param {Function} props.onSelectionsChanged Callback for when selections are added or removed * @param {Function} props.onFocus Callback for when the component receives focus + * @param {boolean} props.allowUserDefinedSelections Whether a user can create new selections other than the defined suggestions * @param {Function} props.SuggestionTemplate Optional Preact component to render suggestion items * @param {Function} props.SelectionTemplate Optional Preact component to render selected items */ @@ -82,6 +83,7 @@ export const MultiSelectAutocomplete = ({ maxSelections, onSelectionsChanged, onFocus, + allowUserDefinedSelections = false, SuggestionTemplate, SelectionTemplate = DefaultSelectionTemplate, }) => { @@ -200,13 +202,31 @@ export const MultiSelectAutocomplete = ({ const matchingSuggestion = suggestions.find( (suggestion) => suggestion.name === textValue, ); - selectItem({ - selectedItem: matchingSuggestion - ? matchingSuggestion - : { name: textValue }, - nextInputValue, - keepSelecting, - }); + + if (matchingSuggestion) { + selectItem({ + selectedItem: matchingSuggestion, + nextInputValue, + keepSelecting, + }); + return; + } + + // If we allow user's own input as a selection, fallback to that + if (allowUserDefinedSelections) { + selectItem({ + selectedItem: { name: textValue }, + nextInputValue, + keepSelecting, + }); + return; + } + + // If we couldn't select any valid input, and search is terminated, clear the input + if (!keepSelecting) { + inputRef.current.value = ''; + dispatch('setSuggestions', { payload: [] }); + } }; const enterEditState = (editItem, editItemIndex) => { @@ -309,8 +329,8 @@ export const MultiSelectAutocomplete = ({ return; } - // If no results, display current search term as an option - if (results.length === 0 && value !== '') { + // If no results, and user-generated selections are allowed, display current search term as an option + if (allowUserDefinedSelections && results.length === 0 && value !== '') { results.push({ name: value }); } diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx index 03fd1db1c..98a63b677 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx +++ b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.mdx @@ -2,7 +2,7 @@ The MultiSelectAutocomplete component can be used in situations where a user can search for matching options, and select multiple values. -When the user starts typing, an async search will be triggered, and any fetched suggestions displayed in the dropdown. If no suggestions are available, the currently entered search term will be displayed as an option. +When the user starts typing, an async search will be triggered, and any fetched suggestions displayed in the dropdown. ## Browsing and selecting an option @@ -18,6 +18,12 @@ When a user selects a suggestion, it will be displayed as a button group. The fi Both buttons appear in the tab order, and are semantically grouped together for assistive technology. +## User-defined selections + +By default, only suggestions returned by the `fetchSuggestions` callback may be selected. However, an optional prop `allowUserDefinedSelections` may be passed in to allow a user to create their own values to select. + +If this is enabled, the user's current input will also be shown as a suggestion if the `fetchSuggestions` callback fails to return any options. + ## Technical implementation notes ### Search functionality diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx index 569483e15..c767d1d25 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx +++ b/app/javascript/crayons/MultiSelectAutocomplete/__stories__/MultiSelectAutocomplete.stories.jsx @@ -10,6 +10,12 @@ export default { }, }, argTypes: { + allowUserDefinedSelections: { + table: { + defaultValue: { summary: false }, + }, + description: 'Whether or not a user can create new options to select', + }, border: { table: { defaultValue: { summary: false }, @@ -73,6 +79,7 @@ Default.args = { placeholder: 'Add a number...', maxSelections: 4, staticSuggestionsHeading: 'Static suggestions', + allowUserDefinedSelections: false, }; Default.story = { diff --git a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx index cb23c794f..32b141c3b 100644 --- a/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx +++ b/app/javascript/crayons/MultiSelectAutocomplete/__tests__/MultiSelectAutocomplete.test.jsx @@ -123,13 +123,14 @@ describe('', () => { ).toBeInTheDocument(); }); - it('displays search term as an option if no suggestions', async () => { + it('when user-generated selections are allowed, displays search term as an option if no suggestions', async () => { const mockFetchSuggestions = jest.fn(async () => []); const { getByLabelText, getByRole } = render( , ); @@ -210,7 +211,7 @@ describe('', () => { expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument(); }); - it('should select current text on spacebar press', async () => { + it('should select current text on spacebar press, when it matches a suggestion', async () => { const mockFetchSuggestions = jest.fn(async () => [ { name: 'option1' }, { name: 'option2' }, @@ -223,6 +224,55 @@ describe('', () => { />, ); + const input = getByLabelText('Example label'); + input.focus(); + await userEvent.type(input, 'option1 '); + + // It should now be added to the list of selected items + await waitFor(() => + expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(), + ); + expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument(); + }); + + it('should select current text on comma press, when it matches a suggestion', async () => { + const mockFetchSuggestions = jest.fn(async () => [ + { name: 'option1' }, + { name: 'option2' }, + ]); + + const { getByLabelText, getByRole } = render( + , + ); + + const input = getByLabelText('Example label'); + input.focus(); + userEvent.type(input, 'option1,'); + + // It should now be added to the list of selected items + await waitFor(() => + expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(), + ); + expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument(); + }); + + it('should select current text, when it does not match a suggestion, but user generated selections are allowed', async () => { + const mockFetchSuggestions = jest.fn(async () => [ + { name: 'option1' }, + { name: 'option2' }, + ]); + + const { getByLabelText, getByRole } = render( + , + ); + const input = getByLabelText('Example label'); input.focus(); await userEvent.type(input, 'example '); @@ -234,50 +284,31 @@ describe('', () => { expect(getByRole('button', { name: 'Remove example' })).toBeInTheDocument(); }); - it('should select current text on comma press', async () => { + it("doesn't select manual text entry if already selected", async () => { const mockFetchSuggestions = jest.fn(async () => [ { name: 'option1' }, { name: 'option2' }, ]); - const { getByLabelText, getByRole } = render( + const { getByLabelText, getByRole, getAllByRole } = render( , ); - const input = getByLabelText('Example label'); - input.focus(); - userEvent.type(input, 'example,'); - - // It should now be added to the list of selected items - await waitFor(() => - expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument(), - ); - expect(getByRole('button', { name: 'Remove example' })).toBeInTheDocument(); - }); - - it("doesn't select manual text entry if already selected", async () => { - const { getByLabelText, getByRole, getAllByRole } = render( - []} - />, - ); - // Item should already be selected, as passed as a default value - expect(getByRole('button', { name: 'Edit example' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(); const input = getByLabelText('Example label'); input.focus(); // Try to select the same value by manually typing - await userEvent.type(input, 'example,'); + await userEvent.type(input, 'option1,'); - expect(input).toHaveValue(''); - // Verify there is still only one selected 'example' - expect(getAllByRole('button', { name: 'Edit example' })).toHaveLength(1); + // Verify there is still only one selected 'option1', and text field has not been cleared + expect(input).toHaveValue('option1'); + expect(getAllByRole('button', { name: 'Edit option1' })).toHaveLength(1); }); it('displays selections in a custom template, if provided', async () => { @@ -298,12 +329,12 @@ describe('', () => { const input = getByLabelText('Example label'); - await userEvent.type(input, 'example,'); + await userEvent.type(input, 'option1,'); // It should now be added to the list of selected items using the custom template await waitFor(() => expect( - getByRole('button', { name: 'Selected: example' }), + getByRole('button', { name: 'Selected: option1' }), ).toBeInTheDocument(), ); }); @@ -410,7 +441,7 @@ describe('', () => { expect(getByLabelText('Example label')).toHaveFocus(); }); - it('closes dropdown and selects current input value on blur', async () => { + it('closes dropdown and selects current input value on blur if it matches a suggestion', async () => { const mockFetchSuggestions = jest.fn(async () => [ { name: 'option1' }, { name: 'option2' }, @@ -425,7 +456,7 @@ describe('', () => { const input = getByLabelText('Example label'); input.focus(); - await userEvent.type(input, 'a'); + await userEvent.type(input, 'option1'); await waitFor(() => expect(getByRole('option', { name: 'option1' })).toBeInTheDocument(), @@ -439,8 +470,63 @@ describe('', () => { ); // Text value should be selected - expect(getByRole('button', { name: 'Edit a' })).toBeInTheDocument(); - expect(getByRole('button', { name: 'Remove a' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Edit option1' })).toBeInTheDocument(); + expect(getByRole('button', { name: 'Remove option1' })).toBeInTheDocument(); + }); + + it('clears input without selecting text on blur if no matching suggestion', async () => { + const { getByLabelText, queryByRole } = render( + []} + />, + ); + + const input = getByLabelText('Example label'); + input.focus(); + await userEvent.type(input, 'example'); + + input.blur(); + + // Text value should not be selected + expect( + queryByRole('button', { name: 'Edit example' }), + ).not.toBeInTheDocument(); + expect( + queryByRole('button', { name: 'Remove example' }), + ).not.toBeInTheDocument(); + + // Input should be cleared + expect(input).toHaveValue(''); + }); + + it('clears input and selects current text on blur if no matching suggestion and user-defined selections are permitted', async () => { + const { getByLabelText, queryByRole } = render( + []} + allowUserDefinedSelections={true} + />, + ); + + const input = getByLabelText('Example label'); + input.focus(); + await userEvent.type(input, 'example'); + + input.blur(); + + await waitFor(() => { + // Text value should be selected + expect( + queryByRole('button', { name: 'Edit example' }), + ).toBeInTheDocument(); + expect( + queryByRole('button', { name: 'Remove example' }), + ).toBeInTheDocument(); + + // Input should be cleared + expect(input).toHaveValue(''); + }); }); it('clears the input on Escape press', async () => { @@ -534,10 +620,10 @@ describe('', () => { expect(queryByText('Only 2 selections allowed')).toBeNull(); expect(input).toHaveAttribute('aria-disabled', 'false'); - await userEvent.type(input, 'example,'); + await userEvent.type(input, 'option1,'); // Make sure option has been selected - await waitFor(() => getByRole('button', { name: 'Edit example' })); + await waitFor(() => getByRole('button', { name: 'Edit option1' })); // Check input is in the max reached state expect(input).toHaveAttribute('placeholder', ''); @@ -545,13 +631,13 @@ describe('', () => { expect(input).toHaveAttribute('aria-disabled', 'true'); // Start typing and make sure further options not shown - await userEvent.type(input, 'a'); + await userEvent.type(input, 'option2'); expect(queryAllByRole('option')).toHaveLength(0); expect(getByText('Only 2 selections allowed')).toBeInTheDocument(); // Try to select a value by typing a comma - await userEvent.type(input, 'b,'); + await userEvent.type(input, ','); // Verify the selection wasn't made, and the text is still in the input - expect(input).toHaveValue('ab'); + expect(input).toHaveValue('option2'); }); }); diff --git a/app/javascript/listings/components/ListingTagsField.jsx b/app/javascript/listings/components/ListingTagsField.jsx index 65d464468..4e6f8ac9a 100644 --- a/app/javascript/listings/components/ListingTagsField.jsx +++ b/app/javascript/listings/components/ListingTagsField.jsx @@ -96,6 +96,7 @@ export const ListingTagsField = ({ onSelectionsChanged={syncSelections} inputId="tag-input" onFocus={onFocus} + allowUserDefinedSelections={true} /> {/* Hidden input to store the selected tags and be sent via form data */} {name && }