Allow functions for listbox and defaultListbox props

Extend listbox and defaultListbox prop types to allow functions returning a promise resolving to an array of group settings
This commit is contained in:
Tom Southall
2022-04-02 23:35:52 +01:00
parent 791622770a
commit c1d53e1d2b
8 changed files with 307 additions and 59 deletions

View File

@@ -1,5 +1,8 @@
# Changelog
### 1.3.0 (2 Apr 2022)
- Extend listbox and defaultListbox prop types to allow functions returning a promise resolving to an array of group settings
### 1.2.3 (25 Mar 2022)
- Left align container div contents when focussed

View File

@@ -217,7 +217,7 @@ in order to exit the focused state of the search box.
- Set to `0` if you want no wait at all (e.g. if your listbox data is not fetched asynchronously)
#### `defaultListbox`
- Type: `array` or `object`
- Type: `array` or `object` or `function`
- Default: `undefined`
- The default listbox is displayed when the search box has focus and is empty.
- Supply an array if you wish multiple groups of items to appear in the default listbox. Groups can
@@ -256,6 +256,31 @@ in order to exit the focused state of the search box.
data: () => fetch(`/api/cities/popular`).then(res => res.json()),
}
```
- Supply a function if you wish to dynamically build your listbox contents. One example might be
where you have a data source that already groups results such as a GraphQL query. The function must return a promise which resolves to an array structured exactly as detailed above (see "supply an array..."). For example:
```jsx
(query) => fetch(`/api/default-locations`)
.then(res => res.json())
.then(locations => {
const {recentSearches, popularCities} = locations
return [
{
name: 'Recent Searches',
displayField: 'name',
data: recentSearches,
id: 'recent',
ratio: 1
},
{
name: 'Popular Cities',
displayField: 'name',
data: popularCities,
id: 'popular',
ratio: 1
}
]
})
```
- See the [listbox](#listbox) prop for details on the data structure of groups as these are the same for both `defaultListbox` and `listbox`
#### `defaultListboxIsImmutable`
@@ -292,7 +317,7 @@ in order to exit the focused state of the search box.
#### `listbox`
- **Required**
- Type: `array` or `object`
- Type: `array` or `object` or `function`
- Specifies how listbox results are populated in response to a user's query entered into the search box.
- **Supplying an array**
Supply an array if you wish multiple groups of items to appear in the default listbox. Groups can
@@ -329,12 +354,15 @@ in order to exit the focused state of the search box.
- The function receives a `query` argument which is a string containing the text entered into the search box. The function would then typically perform a fetch to an API endpoint for matching items and finally formats the data received as required.
- If possible, the function should return enough items to satisfy the `maxItems` prop, in case all of the other groups return zero matches.
- See the example above for `data` props supplied as functions.
- The array returned will not be filtered according to the `searchType`. The presumption is that
the function will return an array that is already correctly filtered.
**If an array**
- Instead of a function, an array of items, matching and non-matching can be supplied and Turnstone filters this down to items that match the query.
- Items can be objects, arrays or strings.
- The contents of the array will be filtered down to items matching the user's query based on the `searchType` (see below).
- **`displayField`** (string or number or undefined)
- This indicates the field within each item in the data array that contains the text to be displayed in the listbox.
- This indicates the field within each item in the data array that contains the text to be displayed in the listbox and the text that will be matched to the user's query.
- If the item is an object or array, `displayField` must be a string or number.
- If the item is a string, `displayField` can be omitted.
- **`searchType`** (string)
@@ -366,6 +394,38 @@ in order to exit the focused state of the search box.
- **`displayField`**
- **`searchType`**
See above for explanations of each field.
- **Supplying a function**
Supplying a function is useful if you wish to dynamically build your listbox contents based
on the user's query. One example might be where you have a data source that already groups results such as a GraphQL query.
The function receives a single string argument representing the user's query entered into the search box
The function must return a promise which resolves to an array structured exactly as detailed above in "Supplying an array". For example:
```jsx
(query) => fetch(`/api/locations?q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(locations => {
const {cities, airports} = locations
return [
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: cities,
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: airports,
searchType: 'contains'
}
]
})
```
#### `listboxIsImmutable`
- Type: `boolean`

View File

@@ -40,6 +40,36 @@ const listbox = [
}
]
// // UNCOMMENT FOR TESTING LISTBOX PROP SUPPLIED AS A FUNCTION
// const listbox = query => {
// return fetch(
// `${apiHost}/api/search/locations?q=${encodeURIComponent(query)}&limit=${maxItems}`
// )
// .then(response => response.json())
// .then(locations => {
// const {cities, airports} = locations
// return [
// {
// id: 'cities',
// name: 'Cities',
// ratio: 8,
// displayField: 'name',
// data: cities,
// searchType: 'startswith'
// },
// {
// id: 'airports',
// name: 'Airports',
// ratio: 2,
// displayField: 'name',
// data: airports,
// searchType: 'contains'
// }
// ]
// })
// }
const App = () => {
const [selectedItem, setSelectedItem] = useState(undef)

View File

@@ -1,6 +1,6 @@
{
"name": "turnstone",
"version": "1.2.3",
"version": "1.3.0",
"description": "React customisable autocomplete component with typeahead and grouped results from multiple APIs.",
"author": "Tom Southall",
"keywords": [

View File

@@ -38,6 +38,7 @@ export default function Container(props) {
enterKeyHint,
errorMessage,
id,
listbox,
listboxIsImmutable,
maxItems,
minQueryLength,
@@ -56,11 +57,6 @@ export default function Container(props) {
Clear
} = props
// Destructure listbox prop
const listbox = Array.isArray(props.listbox)
? props.listbox
: [{ ...props.listbox, ...{ name: '', ratio: maxItems } }]
const listboxId = `${id}-listbox`
const errorboxId = `${id}-errorbox`

View File

@@ -3,6 +3,16 @@ import firstOfType from 'first-of-type'
import swrLaggyMiddleware from '../../utils/swrLaggyMiddleware'
import isUndefined from '../../utils/isUndefined'
const convertListboxToFunction = (listbox, maxItems) => {
if(typeof listbox === 'function') return listbox
return () => Promise.resolve(
Array.isArray(listbox)
? listbox
: [{ ...listbox, ...{ name: '', ratio: maxItems } }]
)
}
const filterSuppliedData = (group, query) => {
const { data, displayField, searchType } = group
const caseInsensitiveSearchType = searchType
@@ -83,37 +93,41 @@ export const fetcher = (query, listbox, defaultListbox, minQueryLength, maxItems
const isDefaultListbox = (defaultListbox && !query.length)
const listboxProp = isDefaultListbox ? defaultListbox : listbox
const listboxPromise = (convertListboxToFunction(
isDefaultListbox ? defaultListbox : listbox,
maxItems
))(query)
const promises = listboxProp.map((group) => {
if (typeof group.data === 'function') {
return group.data(query)
}
else {
return Promise.resolve(filterSuppliedData(group, query))
}
})
return listboxPromise.then(listboxProp => {
const promises = listboxProp.map(
group => (typeof group.data === 'function')
? group.data(query)
: Promise.resolve(filterSuppliedData(group, query))
)
return Promise.all(promises).then((groups) => {
groups = groups.reduce((prevGroups, group, groupIndex) => {
return [
...prevGroups,
group.map((item) => ({
value: item,
text: itemText(item, listboxProp[groupIndex].displayField),
groupIndex,
groupId: listboxProp[groupIndex].id,
groupName: listboxProp[groupIndex].name,
searchType: listboxProp[groupIndex].searchType,
displayField: listboxProp[groupIndex].displayField,
defaultListbox: isDefaultListbox
}))
]
}, [])
return Promise.all(promises).then(groups => {
groups = groups.reduce((prevGroups, group, groupIndex) => {
const {id: groupId, name: groupName, displayField, searchType} = listboxProp[groupIndex]
if (groups.length) groups = limitResults(groups, listboxProp, maxItems)
return [
...prevGroups,
group.map((item) => ({
value: item,
text: itemText(item, displayField),
groupIndex,
groupId,
groupName,
searchType,
displayField,
defaultListbox: isDefaultListbox
}))
]
}, [])
return groups.flat()
if (groups.length) groups = limitResults(groups, listboxProp, maxItems)
return groups.flat()
})
})
}

View File

@@ -24,10 +24,42 @@ const server = setupServer(
return res(
ctx.json(fruits.filter(fruit => fruit.toLowerCase().startsWith(q.toLowerCase())))
)
}),
rest.get('http://mock-api-site.com/api/fruits-veg', (req, res, ctx) => {
const q = req.url.searchParams.get('q')
return res(
ctx.json({
fruits: fruits.filter(fruit => fruit.toLowerCase().startsWith(q.toLowerCase())),
veg: vegetables.filter(vegetable => vegetable.toLowerCase().startsWith(q.toLowerCase()))
})
)
})
)
const apiListbox = [
const item = (props) => {
return {
...{
value: undef,
text: undef,
groupIndex: undef,
groupName: undef,
defaultListbox: undef,
displayField: undef,
groupId: undef,
searchType: undef
},
...props
}
}
const apiListboxObject = {
data: (query) =>
fetch(`http://mock-api-site.com/api/fruits?q=${encodeURIComponent(query)}`)
.then(response => response.json())
}
const apiListboxArray = [
{
id: 'books',
name: 'Books',
@@ -46,7 +78,28 @@ const apiListbox = [
}
]
describe('Fetching API data', () => {
const apiListboxFunction = query => {
return fetch(`http://mock-api-site.com/api/fruits-veg?q=${encodeURIComponent(query)}`)
.then(response => response.json())
.then(fruitsAndVeg => {
const {fruits, veg} = fruitsAndVeg
return [
{
name: 'Fruits',
ratio: 8,
data: fruits
},
{
name: 'Vegetables',
ratio: 2,
data: veg
}
]
})
}
describe('Fetching API data using a listbox array', () => {
beforeAll(() => {
server.listen()
})
@@ -56,55 +109,146 @@ describe('Fetching API data', () => {
})
test('Returns expected results', () => {
return fetcher('L', apiListbox, undef, 1, 6).then(items => {
return fetcher('L', apiListboxObject, undef, 1, 6).then(items => {
expect(items).toEqual([
{
item({
value: 'Legume',
text: 'Legume',
groupIndex: 0,
groupName: '',
}),
item({
value: 'Lemon',
text: 'Lemon',
groupIndex: 0,
groupName: ''
}),
item({
value: 'Lime',
text: 'Lime',
groupIndex: 0,
groupName: ''
}),
item({
value: 'Lychee',
text: 'Lychee',
groupIndex: 0,
groupName: ''
}),
])
})
})
})
describe('Fetching API data using a listbox array', () => {
beforeAll(() => {
server.listen()
})
afterAll(() => {
server.close()
})
test('Returns expected results', () => {
return fetcher('L', apiListboxArray, undef, 1, 6).then(items => {
expect(items).toEqual([
item({
value: { title: 'Last Argument of Kings', author: 'Joe Abercrombie' },
text: 'Last Argument of Kings',
groupIndex: 0,
groupName: 'Books',
defaultListbox: undef,
displayField: 'title',
groupId: 'books',
searchType: undef
},
{
}),
item({
value: { title: 'Legend', author: 'Marie Lu' },
text: 'Legend',
groupIndex: 0,
groupName: 'Books',
defaultListbox: undef,
displayField: 'title',
groupId: 'books',
searchType: undef
},
{
}),
item({
value: { title: 'Life After Life', author: 'Kate Atkinson' },
text: 'Life After Life',
groupIndex: 0,
groupName: 'Books',
defaultListbox: undef,
displayField: 'title',
groupId: 'books',
searchType: undef
},
{
}),
item({
value: { title: 'Like Water for Chocolate', author: 'Laura Esquivel' },
text: 'Like Water for Chocolate',
groupIndex: 0,
groupName: 'Books',
defaultListbox: undef,
displayField: 'title',
groupId: 'books',
searchType: undef
},
{
}),
item({
value: 'Legume',
text: 'Legume',
groupIndex: 1,
groupName: 'Fruits',
}),
item({
value: 'Lemon',
text: 'Lemon',
groupIndex: 1,
groupName: 'Fruits'
},
{ value: 'Lemon', text: 'Lemon', groupIndex: 1, groupName: 'Fruits' }
})
])
})
})
})
describe('Fetching API data using a listbox function', () => {
beforeAll(() => {
server.listen()
})
afterAll(() => {
server.close()
})
test('Returns expected results', () => {
return fetcher('L', apiListboxFunction, undef, 1, 6).then(items => {
expect(items).toEqual([
item({
value: 'Legume',
text: 'Legume',
groupIndex: 0,
groupName: 'Fruits',
}),
item({
value: 'Lemon',
text: 'Lemon',
groupIndex: 0,
groupName: 'Fruits'
}),
item({
value: 'Lime',
text: 'Lime',
groupIndex: 0,
groupName: 'Fruits'
}),
item({
value: 'Lychee',
text: 'Lychee',
groupIndex: 0,
groupName: 'Fruits'
}),
item({
value: 'Leek',
text: 'Leek',
groupIndex: 1,
groupName: 'Vegetables'
}),
item({
value: 'Legumes',
text: 'Legumes',
groupIndex: 1,
groupName: 'Vegetables'
})
])
})
})

View File

@@ -81,7 +81,8 @@ const listboxRules = PropTypes.oneOfType([
]).isRequired,
searchType: PropTypes.oneOf(searchTypes),
displayField: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
})
}),
PropTypes.func
])
Turnstone.propTypes = {