Skip to content

Commit

Permalink
STCOM-1345 <Selection> - Call onFilter in side-effect for `asyncF…
Browse files Browse the repository at this point in the history
…ilter` (#2358)

* implement async filtering as side-effect in Selection

* Selection - asyncFiltering documentation.

* asyncfilter is only boolean

* add test for asyncFilter number of calls

* Update readme.md

* Update en.json

* Update en_US.json
  • Loading branch information
JohnC-80 authored Oct 7, 2024
1 parent 20c4778 commit d7e8ae8
Show file tree
Hide file tree
Showing 8 changed files with 205 additions and 35 deletions.
58 changes: 27 additions & 31 deletions lib/MultiColumnList/stories/service.js
Original file line number Diff line number Diff line change
@@ -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);
});
Expand Down
26 changes: 24 additions & 2 deletions lib/Selection/Selection.js
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ const getClass = ({
/* eslint-disable prefer-arrow-callback */

const Selection = ({
asyncFilter,
asyncFilter = false,
autofocus,
dataOptions,
dirty,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -304,6 +311,21 @@ const Selection = ({
}

/* no options found through async filter */
if (asyncFilter &&
dataOptions.length === 0 &&
debouncedFilterValue === ''
) {
return (
<li
role="option"
className={css.option}
aria-selected="false"
>
<span>-{formatMessage({ id: 'stripes-components.selection.enterFilter' })}-</span>
</li>
);
}

if (dataOptions.length === 0) {
return (
<li
Expand Down Expand Up @@ -445,7 +467,7 @@ const Selection = ({
};

Selection.propTypes = {
asyncFilter: PropTypes.func,
asyncFilter: PropTypes.bool,
autofocus: PropTypes.bool,
dataOptions: PropTypes.arrayOf(PropTypes.object),
dirty: PropTypes.bool,
Expand Down
27 changes: 26 additions & 1 deletion lib/Selection/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ customFormatter = ({ option, searchTerm }) => {
## 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. | | &#10004;
`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
Expand Down Expand Up @@ -104,7 +105,31 @@ dataOptions = {[
```
See storybook for complete usage example.

## Usage in Redux-form
## Async Filtering
Default functionality of `<Selection>` 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...
};
<Selection
label="Async select"
dataOptions={data}
asyncFilter
onFilter={handleFilter}
loading={loading}
/>
```

## Usage in React Final Form
Redux form will provide `input` and `meta` props to the component when it is used with a redux-form `<Field>` component. The component's value and validation are supplied through these.
```
<Field name="SelectionCountry" label="Country" id="countrySelect" placeholder="Select country" component={Selection} dataOptions={countriesOptions}/>
Expand Down
90 changes: 90 additions & 0 deletions lib/Selection/stories/AsyncFilter.js
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<Selection
name="SelectionCountry"
label="Country Long List"
id="countryLongSelect"
placeholder="Select country"
dataOptions={data}
asyncFilter
onFilter={handleFilter}
loading={loading}
/>
<Selection
name="SelectionCountry"
label="Initially Empty List"
id="countryLongSelectEmpty"
placeholder="Select city"
dataOptions={empty}
asyncFilter
onFilter={handleFilterEmpty}
loading={loading}
/>
</div>
)
};
2 changes: 2 additions & 0 deletions lib/Selection/stories/Selection.stories.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import React from 'react';
import BasicUsage from './BasicUsage';
import GroupedOptions from './GroupedOptions';
import AsyncFilter from './AsyncFilter';

export default {
title: 'Selection',
};

export const _BasicUsage = () => <BasicUsage />;
export const _GroupedOptions = () => <GroupedOptions />;
export const _AsyncFilter = () => <AsyncFilter />;
33 changes: 33 additions & 0 deletions lib/Selection/tests/Selection-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]')
Expand Down Expand Up @@ -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(
<AsyncFilter filterSpy={filterSpy}/>
);
});

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 }));
})
})
});
1 change: 1 addition & 0 deletions translations/stripes-components/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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...",
Expand Down
3 changes: 2 additions & 1 deletion translations/stripes-components/en_US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -894,4 +895,4 @@
"advancedSearch.match.containsAny": "Contains any",
"saveAndKeepEditing": "Save & keep editing",
"multiSelection.dropdownTriggerLabel": "open menu"
}
}

0 comments on commit d7e8ae8

Please sign in to comment.