diff --git a/lib/MultiColumnList/stories/service.js b/lib/MultiColumnList/stories/service.js index ca23e5ff7..1c35c391f 100644 --- a/lib/MultiColumnList/stories/service.js +++ b/lib/MultiColumnList/stories/service.js @@ -1,61 +1,57 @@ import faker from 'faker'; -export function syncGenerate(n, start = 0, generator) { +const baseGenerator = (start, res, i) => ({ + active: faker.random.boolean(), + title: faker.random.words(), + index: (start || 0) + res.length, + date: faker.date.past().toString(), + email: faker.internet.email(), + status: faker.random.boolean(), + phone: faker.phone.phoneNumber(), + name: faker.name.findName(), +}); + +const baseGenerateSparse = (start, res, i) => ({ + active: faker.random.boolean(), + title: faker.random.words(), + index: start + i + 1, + date: faker.date.past().toString(), + email: faker.internet.email(), +}); + +export function syncGenerate(n, start = 0, generator = baseGenerator) { const res = []; let i = start; while (i < n) { - if (generator) { - res.push(generator()); - } else { - res.push({ - active: faker.random.boolean(), - title: faker.random.words(), - index: (start || 0) + res.length, - date: faker.date.past().toString(), - email: faker.internet.email(), - status: faker.random.boolean(), - phone: faker.phone.phoneNumber(), - name: faker.name.findName(), - }); - } + res.push(generator(start, res, i)); i++; } return res; } -export function asyncGenerate(n, start, timeout = 0, test = false) { +export function asyncGenerate(n, start, timeout = 0, test = false, generator = baseGenerator) { return new Promise(resolve => { setTimeout(() => { - const res = syncGenerate(n, start); + const res = syncGenerate(n, start, generator); resolve(res); }, test ? 0 : timeout); }); } -export function syncGenerateSparse(n, start, length, generator) { +export function syncGenerateSparse(n, start, length, generator = baseGenerateSparse) { const res = new Array(start); let i = 0; while (i < n) { - if (generator) { - res.push(generator()); - } else { - res.push({ - active: faker.random.boolean(), - title: faker.random.words(), - index: start + i + 1, - date: faker.date.past().toString(), - email: faker.internet.email(), - }); - } + res.push(generator(start, res, i)); i++; } return res; } -export function asyncGenerateSparse(n, start, length, timeout = 0) { +export function asyncGenerateSparse(n, start, length, timeout = 0, generator = baseGenerator) { return new Promise(resolve => { setTimeout(() => { - const res = syncGenerateSparse(n, start, length); + const res = syncGenerateSparse(n, start, length, generator); resolve(res); }, timeout); }); diff --git a/lib/Selection/Selection.js b/lib/Selection/Selection.js index e363ce873..a645aa4e3 100644 --- a/lib/Selection/Selection.js +++ b/lib/Selection/Selection.js @@ -90,7 +90,7 @@ const getClass = ({ /* eslint-disable prefer-arrow-callback */ const Selection = ({ - asyncFilter, + asyncFilter = false, autofocus, dataOptions, dirty, @@ -164,6 +164,13 @@ const Selection = ({ [options, hasGroups] ) + // with async filtering, the filtering happens as a side-effect. + useEffect(() => { + if (asyncFilter && debouncedFilterValue) { + filterFn(dataOptions); + } + }, [debouncedFilterValue, asyncFilter]); + const { isOpen, getToggleButtonProps, @@ -304,6 +311,21 @@ const Selection = ({ } /* no options found through async filter */ + if (asyncFilter && + dataOptions.length === 0 && + debouncedFilterValue === '' + ) { + return ( +
  • + -{formatMessage({ id: 'stripes-components.selection.enterFilter' })}- +
  • + ); + } + if (dataOptions.length === 0) { return (
  • { ## Props Name | type | description | default | required --- | --- | --- | --- | --- +`asyncFilter` | bool | Set to `true` if your `onFilter` function makes a request for updated `dataOptions` rather than filter the `dataOptions` directly on the front-end. | `false` | false `dataOptions` | array of objects | Array of objects with `label` and `value` keys. The labels are visible to the users in the options list, the values are not. | | ✔ `id` | string | Sets the `id` html attribute on the control | | `inputRef` | object/func | Reference object/function for accessing the contro button DOM element. | ref | false @@ -104,7 +105,31 @@ dataOptions = {[ ``` See storybook for complete usage example. -## Usage in Redux-form +## Async Filtering +Default functionality of `` is client-side filtering of options, but it can set up to perform filtering via a request which would simply update the provided `dataOptions` list. Just provide the `asyncFilter` boolean prop and implement something similar to the following example for your `onFilter` function. + +``` +const [data, setData] = useState(hugeOptionsList); +const [loading, setLoading] = useState(false); + +const handleFilter = async (filter) => { + setLoading(true); // So that the component can render a loading spinner while the user waits... + setData([]); // empty data so that none will render... + const newData = await ... // perform request here... + setData(newData); // supply the new dataOptions + setLoading(false); // turn off the loading spinner... +}; + + +``` + +## Usage in React Final Form Redux form will provide `input` and `meta` props to the component when it is used with a redux-form `` component. The component's value and validation are supplied through these. ``` diff --git a/lib/Selection/stories/AsyncFilter.js b/lib/Selection/stories/AsyncFilter.js new file mode 100644 index 000000000..fd57e0d40 --- /dev/null +++ b/lib/Selection/stories/AsyncFilter.js @@ -0,0 +1,90 @@ +/** + * Selection basic usage + */ + +import { useCallback, useState } from 'react'; +import faker from 'faker'; +import Selection from '../Selection'; +import { syncGenerate, asyncGenerate } from '../../MultiColumnList/stories/service'; + + +const hugeOptionsList = syncGenerate(3000, 0, () => { + const item = faker.address.city(); + return { value: item, label: item }; +}); + +// the dataOptions prop takes an array of objects with 'label' and 'value' keys +const countriesOptions = [ + { value: 'AU', label: 'Australia' }, + { value: 'CN', label: 'China' }, + { value: 'DK', label: 'Denmark' }, + { value: 'MX', label: 'Mexico' }, + { value: 'SE', label: 'Sweden' }, + { value: 'US', label: 'United States' }, + { value: 'UK', label: 'United Kingdom' }, + // ...obviously there are more.... +]; + + + +export default ({ filterSpy = () => {} }) => { + const [data, setData] = useState(hugeOptionsList); + const [empty, setEmpty] = useState([]); + const [loading, setLoading] = useState(false); + + const handleFilter = async ( + filter, + // dataOptions + ) => { + setLoading(true); + setData([]); + if (filter) { + const newData = await asyncGenerate(30, 0, 1000, false, () => { + const item = faker.address.city(); + return { value: item, label: item }; + }); + filterSpy(); + setData(newData); + } + setLoading(false); + }; + + const handleFilterEmpty = async (filter) => { + setLoading(true); + setEmpty([]); + if (filter) { + const newData = await asyncGenerate(30, 0, 1000, false, () => { + const item = faker.address.city(); + return { value: item, label: item }; + }); + filterSpy(); + setEmpty(newData); + } + setLoading(false); + }; + + return ( +
    + + +
    + ) +}; diff --git a/lib/Selection/stories/Selection.stories.js b/lib/Selection/stories/Selection.stories.js index 9ca3820e0..ac3bd2029 100644 --- a/lib/Selection/stories/Selection.stories.js +++ b/lib/Selection/stories/Selection.stories.js @@ -1,6 +1,7 @@ import React from 'react'; import BasicUsage from './BasicUsage'; import GroupedOptions from './GroupedOptions'; +import AsyncFilter from './AsyncFilter'; export default { title: 'Selection', @@ -8,3 +9,4 @@ export default { export const _BasicUsage = () => ; export const _GroupedOptions = () => ; +export const _AsyncFilter = () => ; diff --git a/lib/Selection/tests/Selection-test.js b/lib/Selection/tests/Selection-test.js index 4d989d849..2c7a68729 100644 --- a/lib/Selection/tests/Selection-test.js +++ b/lib/Selection/tests/Selection-test.js @@ -20,6 +20,12 @@ import { mountWithContext } from '../../../tests/helpers'; import { RoledHTML } from '../../../tests/helpers/localInteractors'; import Selection from '../Selection'; import SingleSelectionHarness from './SingleSelectionHarness'; +import AsyncFilter from '../stories/AsyncFilter'; + +const asyncSelectionList = SelectListInteractor.extend('selection list with loading') + .filters({ + loading: (el) => (el.querySelector('[class*=spinner]') !== null) + }); const SelectionGroupLabel = HTML.extend('Selection option group') .selector('[class^=groupLabel]') @@ -730,4 +736,31 @@ describe('Selection', () => { it('renders overlay within portal element', () => HTML({ id: 'OverlayContainer' }).find(SelectListInteractor()).exists()); }); + + describe('async filtering', () => { + const asyncSelection = SelectionInteractor('Country Long List'); + const filterSpy = sinon.spy(); + beforeEach(async () => { + filterSpy.resetHistory(); + await mountWithContext( + + ); + }); + + it('renders the control', () => { + asyncSelection.exists(); + }); + + describe('entering filter text', () => { + beforeEach(async () => { + await asyncSelection.filterOptions('tes'); + }); + + it ('calls filter function only once (not for each letter)', async () => converge(() => { if (filterSpy.calledTwice) { throw new Error('Selection - onFilter should only be called once.'); }})); + + it('displays spinner in optionsList', () => asyncSelectionList().is({ loading: true })); + + it('displays resulting options', () => asyncSelectionList().is({ optionCount: 30 })); + }) + }) }); diff --git a/translations/stripes-components/en.json b/translations/stripes-components/en.json index f7fe8f203..b4b167642 100644 --- a/translations/stripes-components/en.json +++ b/translations/stripes-components/en.json @@ -104,6 +104,7 @@ "button.duplicate": "Duplicate", "button.new": "New", "exportToCsv": "Export to CSV", + "selection.enterFilter": "Enter a value to load options", "selection.emptyList": "List is empty", "selection.noMatches": "No matching options", "selection.loading": "Loading options...", diff --git a/translations/stripes-components/en_US.json b/translations/stripes-components/en_US.json index e46ee70bc..67e3262c2 100644 --- a/translations/stripes-components/en_US.json +++ b/translations/stripes-components/en_US.json @@ -48,6 +48,7 @@ "metaSection.source": "Source: {source}", "metaSection.recordCreated": "Record created: {date} {time}", "metaSection.recordLastUpdated": "Record last updated: {date} {time}", + "selection.enterFilter": "Enter a value to load options", "selection.emptyList": "List is empty", "selection.noMatches": "No matching options", "selection.filterOptionsLabel": "{label} options filter", @@ -894,4 +895,4 @@ "advancedSearch.match.containsAny": "Contains any", "saveAndKeepEditing": "Save & keep editing", "multiSelection.dropdownTriggerLabel": "open menu" -} \ No newline at end of file +}