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
+}