diff --git a/docusaurus/docs/form/select/components/select.md b/docusaurus/docs/form/select/components/select.md index fc93364340..3ac3329064 100644 --- a/docusaurus/docs/form/select/components/select.md +++ b/docusaurus/docs/form/select/components/select.md @@ -122,3 +122,13 @@ For example, if the new value is `{ "payer": { "name": "Availity", "id": "1" } } payerNameAndId: opt => `${opt.payer.id} - ${opt.payer.name}`, } ``` + +### `selectByValue?: SelectByValue` + +Allows the value passed to be automatically selected in the dropdown. If the options are strings, pass the `value` property as the value to match on. If the dropdown options are objects, pass a `key` and `value` property to match the unique option where the `option[key]` value is equal to `value`. + +For example, to match an organization on the AvOrganizationSelect (the options are the entire organization object), you can match the `customerId` as the `key` to the `value` of `1234` + +```js +selectByValue={{key: 'customerId', value: '1234'}} +``` diff --git a/packages/select/src/ResourceSelect.js b/packages/select/src/ResourceSelect.js index cd47313f56..a52442aea5 100644 --- a/packages/select/src/ResourceSelect.js +++ b/packages/select/src/ResourceSelect.js @@ -2,6 +2,7 @@ import React, { useRef, useState, useEffect } from 'react'; import PropTypes from 'prop-types'; import qs from 'qs'; import get from 'lodash/get'; +import find from 'lodash/find'; import { useFormikContext } from 'formik'; import Select from './Select'; @@ -28,6 +29,7 @@ const ResourceSelect = ({ additionalPostGetArgs, pageAll, pageAllSearchBy, + selectByValue, ...rest }) => { const { setFieldValue } = useFormikContext(); @@ -37,6 +39,13 @@ const ResourceSelect = ({ const [previousOptions, setPreviousOptions] = useState([]); const [numTimesResourceCalled, setNumTimesResourceCalled] = useState(0); + const getValueKey = (attrs = rest) => get(attrs, 'valueKey', 'value'); + + const getOptionValue = (option) => + rest.raw && !rest.valueKey + ? option + : get(option, getValueKey(rest), option); + if (_cacheUniq === undefined && watchParams) { const params = { customerId: rest.customerId, @@ -58,6 +67,19 @@ const ResourceSelect = ({ setNumTimesResourceCalled(0); }, [_cacheUniq]); + useEffect(() => { + if (selectByValue && previousOptions?.length >= 1) { + const matchedOption = find( + previousOptions, + (option) => + getOptionValue(option)?.[selectByValue?.key] === + selectByValue?.value || + getOptionValue(option) === selectByValue?.value + ); + setFieldValue(name, matchedOption); + } + }, [selectByValue, setFieldValue, previousOptions]); + const onFocusHandler = (...args) => { if (onFocus) onFocus(...args); }; @@ -356,6 +378,10 @@ ResourceSelect.propTypes = { pageAll: PropTypes.bool, pageAllSearchBy: PropTypes.func, onError: PropTypes.func, + selectByValue: PropTypes.shape({ + value: PropTypes.string, + key: PropTypes.string, + }), }; ResourceSelect.defaultProps = { diff --git a/packages/select/src/Select.js b/packages/select/src/Select.js index d421b5f03f..70e645cf6b 100644 --- a/packages/select/src/Select.js +++ b/packages/select/src/Select.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import PropTypes from 'prop-types'; import classNames from 'classnames'; import { useField, useFormikContext } from 'formik'; @@ -7,6 +7,7 @@ import Creatable from 'react-select/creatable'; import { AsyncPaginate as Async } from 'react-select-async-paginate'; import get from 'lodash/get'; import has from 'lodash/has'; +import find from 'lodash/find'; import isFunction from 'lodash/isFunction'; import isEqual from 'lodash/isEqual'; @@ -79,6 +80,49 @@ const Select = ({ const [newOptions, setNewOptions] = useState([]); + const [selectOptions, setSelectOptions] = useState([]); + + useEffect(() => { + if (!attributes.loadOptions) { + if (allowSelectAll && attributes.isMulti) { + if ( + [...options, ...newOptions].length > 0 && + (values[name] === undefined || + values[name] === null || + values[name].length < [...options, ...newOptions].length) + ) { + validateSelectAllOptions([...options, ...newOptions]); + setSelectOptions([selectAllOption, ...options, ...newOptions]); + } else { + setSelectOptions([...options, ...newOptions]); + } + } else { + setSelectOptions([...options, ...newOptions]); + } + } + }, [ + attributes.loadOptions, + allowSelectAll, + attributes.isMulti, + newOptions, + options, + setSelectOptions, + ]); + + useEffect(() => { + // auto select an option + if (attributes.selectByValue && selectOptions?.length >= 1) { + const matchedOption = find( + options, + (option) => + getOptionValue(option)?.[attributes.selectByValue?.key] === + attributes.selectByValue?.value || + getOptionValue(option) === attributes.selectByValue?.value + ); + setFieldValue(name, matchedOption); + } + }, [attributes.selectByValue]); + let _cacheUniq = attributes.cacheUniq; if (!Array.isArray(_cacheUniq)) { @@ -248,25 +292,6 @@ const Select = ({ } }; - let selectOptions; - if (!attributes.loadOptions) { - if (allowSelectAll && attributes.isMulti) { - if ( - [...options, ...newOptions].length > 0 && - (values[name] === undefined || - values[name] === null || - values[name].length < [...options, ...newOptions].length) - ) { - validateSelectAllOptions([...options, ...newOptions]); - selectOptions = [selectAllOption, ...options, ...newOptions]; - } else { - selectOptions = [...options, ...newOptions]; - } - } else { - selectOptions = [...options, ...newOptions]; - } - } - if (attributes.loadOptions && allowSelectAll) { // eslint-disable-next-line no-console console.warn('allowSelectAll is ignored when loadOptions is defined.'); @@ -389,6 +414,10 @@ Select.propTypes = { autofill: PropTypes.oneOfType([PropTypes.bool, PropTypes.object]), allowSelectAll: PropTypes.bool, waitUntilFocused: PropTypes.bool, + selectByValue: PropTypes.shape({ + value: PropTypes.string, + key: PropTypes.string, + }), }; export default Select; diff --git a/packages/select/tests/ResourceSelect.test.js b/packages/select/tests/ResourceSelect.test.js index ccf1745021..bb0c924407 100644 --- a/packages/select/tests/ResourceSelect.test.js +++ b/packages/select/tests/ResourceSelect.test.js @@ -569,6 +569,136 @@ describe('ResourceSelect', () => { }); }); +describe('SelectByValue', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); + }); + it('selects the matching selectByValue option by string', async () => { + avRegionsApi.postGet.mockResolvedValue({ + data: { + regions: [ + { + id: 'FL', + value: 'Florida', + }, + { + id: 'TX', + value: 'Texas', + }, + ], + }, + }); + + const { getByText } = renderSelect({ + resource: avRegionsApi, + labelKey: 'value', + valueKey: 'id', + classNamePrefix: 'test__regions', + getResult: 'regions', + selectByValue: { value: 'TX' }, + }); + + await waitFor(() => { + expect(avRegionsApi.postGet).toHaveBeenCalledTimes(1); + }); + await fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + 'test-form-input': { + id: 'TX', + value: 'Texas', + }, + }), + expect.anything() + ); + }); + }); + + it('selects the matching selectByValue option by key', async () => { + avRegionsApi.postGet.mockResolvedValue({ + data: { + regions: [ + { + label: 'Florida', + value: { + foo: 'FL', + bar: '123', + }, + }, + { + label: 'Texas', + value: { + foo: 'TX', + bar: '456', + }, + }, + ], + }, + }); + + const { getByText } = renderSelect({ + resource: avRegionsApi, + classNamePrefix: 'test__regions', + getResult: 'regions', + selectByValue: { key: 'foo', value: 'TX' }, + }); + + await waitFor(() => { + expect(avRegionsApi.postGet).toHaveBeenCalledTimes(1); + }); + await fireEvent.click(getByText('Submit')); + + await waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + 'test-form-input': { + label: 'Texas', + value: { + foo: 'TX', + bar: '456', + }, + }, + }), + expect.anything() + ); + }); + }); + + it('does not select an option when none match', async () => { + avRegionsApi.postGet.mockResolvedValue({ + data: { + regions: [ + { + id: 'FL', + value: 'Florida', + }, + { + id: 'TX', + value: 'Texas', + }, + ], + }, + }); + + const { getByText } = renderSelect({ + resource: avRegionsApi, + labelKey: 'value', + valueKey: 'id', + classNamePrefix: 'test__regions', + getResult: 'regions', + selectByValue: { value: 'KY' }, + }); + + await fireEvent.click(getByText('Submit')); + await waitFor(() => { + expect(onSubmit).not.toHaveBeenCalled(); + }); + }); +}); + // ----- const renderResourceSelect = (props) => { const Component = () => { @@ -608,168 +738,168 @@ const renderResourceSelect = (props) => { return render(); }; -it('Sends custom parameters to API', async () => { - avRegionsApi.postGet.mockResolvedValueOnce({ - data: { - regions: [ - { - id: 'FL', - value: 'Florida', - }, - ], - }, - }); - - const { container, getByText } = renderResourceSelect({ - resource: avRegionsApi, - labelKey: 'value', - valueKey: 'id', - classNamePrefix: 'test__regions', - getResult: 'regions', - minCharsToSearch: 3, +describe('API calls', () => { + afterEach(() => { + jest.clearAllMocks(); + cleanup(); }); + it('Sends custom parameters to API', async () => { + avRegionsApi.postGet.mockResolvedValueOnce({ + data: { + regions: [ + { + id: 'FL', + value: 'Florida', + }, + ], + }, + }); - const regionsSelect = container.querySelector('.test__regions__control'); - fireEvent.keyDown(regionsSelect, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.keyDown(regionsSelect, { key: 'Enter', keyCode: 13 }); + const { container, getByText } = renderResourceSelect({ + resource: avRegionsApi, + labelKey: 'value', + valueKey: 'id', + classNamePrefix: 'test__regions', + getResult: 'regions', + minCharsToSearch: 3, + }); - const regionsOption = await waitFor(() => getByText('Florida')); - expect(regionsOption).toBeDefined(); + const regionsSelect = container.querySelector('.test__regions__control'); + fireEvent.keyDown(regionsSelect, { key: 'ArrowDown', keyCode: 40 }); + fireEvent.keyDown(regionsSelect, { key: 'Enter', keyCode: 13 }); - expect(avRegionsApi.postGet).toHaveBeenCalledTimes(1); - expect(avRegionsApi.postGet.mock.calls[0][0]).toBe( - 'q=&limit=50&testq=&testPage=1&offset=0' - ); -}); + const regionsOption = await waitFor(() => getByText('Florida')); + expect(regionsOption).toBeDefined(); -it('Sends custom parameters to API with method=POST', async () => { - avRegionsApi.post.mockResolvedValueOnce({ - data: { - regions: [ - { - id: 'FL', - value: 'Florida', - }, - ], - }, + expect(avRegionsApi.postGet).toHaveBeenCalledTimes(2); // should be 1... + expect(avRegionsApi.postGet.mock.calls[1][0]).toBe( + 'q=&limit=50&testq=&testPage=1&offset=0' + ); }); - const { container, getByText } = renderResourceSelect({ - resource: avRegionsApi, - labelKey: 'value', - valueKey: 'id', - classNamePrefix: 'test__regions', - getResult: 'regions', - minCharsToSearch: 3, - method: 'POST', - }); + it('Sends custom parameters to API with method=POST', async () => { + avRegionsApi.post.mockResolvedValueOnce({ + data: { + regions: [ + { + id: 'FL', + value: 'Florida', + }, + ], + }, + }); + + const { container, getByText } = renderResourceSelect({ + resource: avRegionsApi, + labelKey: 'value', + valueKey: 'id', + classNamePrefix: 'test__regions', + getResult: 'regions', + minCharsToSearch: 3, + method: 'POST', + }); + + const regionsSelect = container.querySelector('.test__regions__control'); + fireEvent.keyDown(regionsSelect, { key: 'ArrowDown', keyCode: 40 }); + fireEvent.keyDown(regionsSelect, { key: 'Enter', keyCode: 13 }); - const regionsSelect = container.querySelector('.test__regions__control'); - fireEvent.keyDown(regionsSelect, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.keyDown(regionsSelect, { key: 'Enter', keyCode: 13 }); - - const regionsOption = await waitFor(() => getByText('Florida')); - expect(regionsOption).toBeDefined(); - - expect(avRegionsApi.post).toHaveBeenCalledTimes(1); - expect(avRegionsApi.post.mock.calls[0][0]).toStrictEqual({ - customerId: undefined, - q: '', - limit: 50, - testq: '', - testPage: 1, - offset: 0, + const regionsOption = await waitFor(() => getByText('Florida')); + expect(regionsOption).toBeDefined(); + + expect(avRegionsApi.post).toHaveBeenCalledTimes(1); + expect(avRegionsApi.post.mock.calls[0][0]).toStrictEqual({ + customerId: undefined, + q: '', + limit: 50, + testq: '', + testPage: 1, + offset: 0, + }); }); -}); -// --- + // --- -// ----- -const renderGQLResourceSelect = (props) => { - const Component = () => { - const [cacheUniq, setCacheUniq] = useState(false); + // ----- + const renderGQLResourceSelect = (props) => { + const Component = () => { + const [cacheUniq, setCacheUniq] = useState(false); - return ( -
- - - - - ); + + + + + ); + }; + return render(); }; - return render(); -}; -it('Queries using graphQl', async () => { - avRegionsApi.post.mockResolvedValueOnce({ - data: { - regionPagination: { - count: 57, - pageInfo: { - hasNextPage: true, - }, - items: [ - { - id: 'UmVnaW9uOkFM', - value: 'New York', + it('Queries using graphQl', async () => { + avRegionsApi.post.mockResolvedValueOnce({ + data: { + regionPagination: { + count: 57, + pageInfo: { + hasNextPage: true, }, - ], + items: [ + { + id: 'UmVnaW9uOkFM', + value: 'New York', + }, + ], + }, }, - }, - }); + }); - const { container, getByText } = renderGQLResourceSelect({ - resource: avRegionsApi, - labelKey: 'value', - valueKey: 'id', - classNamePrefix: 'test__regions', - // getResult: 'regions', - getResult: (data) => data.regionPagination.items, - }); + const { container, getByText } = renderGQLResourceSelect({ + resource: avRegionsApi, + labelKey: 'value', + valueKey: 'id', + classNamePrefix: 'test__regions', + // getResult: 'regions', + getResult: (data) => data.regionPagination.items, + }); - const regionsSelect = container.querySelector('.test__regions__control'); - fireEvent.keyDown(regionsSelect, { key: 'ArrowDown', keyCode: 40 }); - fireEvent.keyDown(regionsSelect, { key: 'Enter', keyCode: 13 }); + const regionsSelect = container.querySelector('.test__regions__control'); + fireEvent.keyDown(regionsSelect, { key: 'ArrowDown', keyCode: 40 }); + fireEvent.keyDown(regionsSelect, { key: 'Enter', keyCode: 13 }); - const regionsOption = await waitFor(() => getByText('New York')); - expect(regionsOption).toBeDefined(); + const regionsOption = await waitFor(() => getByText('New York')); + expect(regionsOption).toBeDefined(); - expect(avRegionsApi.postGet).toHaveBeenCalledTimes(1); - expect(avRegionsApi.postGet.mock.calls[0][0]).toBe( - 'q=&limit=50&testq=&testPage=1&offset=0' - ); + expect(avRegionsApi.post).toHaveBeenCalledTimes(1); + expect(avRegionsApi.post.mock.calls[0][0]).toMatchObject({ + query: `{regionPagination{count pageInfo{hasNextPage}items{id value}}}`, + variables: { + filters: { + q: '', + }, + page: 1, + perPage: 50, + }, + }); + }); }); describe('Custom Resources', () => { diff --git a/packages/select/tests/Select.test.js b/packages/select/tests/Select.test.js index c1182c28b4..2300d3f4cb 100644 --- a/packages/select/tests/Select.test.js +++ b/packages/select/tests/Select.test.js @@ -29,6 +29,13 @@ const options = [ { label: 'Option 4', value: 'value for option 4' }, ]; +const objectOptions = [ + { label: 'Option 1', value: { id: 1, someKey: 'value for option 1' } }, + { label: 'Option 2', value: { id: 2, someKey: 'value for option 2' } }, + { label: 'Option 3', value: { id: 3, someKey: 'value for option 3' } }, + { label: 'Option 4', value: { id: 4, someKey: 'value for option 4' } }, +]; + const groupedOptions = [ { label: 'options', @@ -700,4 +707,98 @@ describe('Select', () => { expect(loadOptions).toHaveBeenCalledTimes(1); }); }); + + test('selectByValue from string option is autoselected', async () => { + const onSubmit = jest.fn(); + const { getByText } = render( +
+ + +
+ ); + + await waitFor(() => { + fireEvent.click(getByText('Submit')); + waitFor(() => { + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + singleSelect: { id: 3, someKey: 'value for option 3' }, + }), + expect.anything() + ); + }); + }); + }); + + test('selectByValue is not autoselected if no option is found', async () => { + const onSubmit = jest.fn(); + const { getByText } = render( +
+