diff --git a/CHANGELOG.md b/CHANGELOG.md index 327e51f..f26f265 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index f065a82..ec6da91 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/examples/geo/App.jsx b/examples/geo/App.jsx index cd7b371..dd136b4 100644 --- a/examples/geo/App.jsx +++ b/examples/geo/App.jsx @@ -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) diff --git a/package.json b/package.json index b74fa6c..d0505dc 100644 --- a/package.json +++ b/package.json @@ -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": [ diff --git a/src/lib/components/container.jsx b/src/lib/components/container.jsx index d7a5772..605f68a 100644 --- a/src/lib/components/container.jsx +++ b/src/lib/components/container.jsx @@ -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` diff --git a/src/lib/components/hooks/useData.js b/src/lib/components/hooks/useData.js index dc0681e..fad00b4 100644 --- a/src/lib/components/hooks/useData.js +++ b/src/lib/components/hooks/useData.js @@ -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() + }) }) } diff --git a/src/lib/components/hooks/useData.test.js b/src/lib/components/hooks/useData.test.js index 8d15741..7f3c599 100644 --- a/src/lib/components/hooks/useData.test.js +++ b/src/lib/components/hooks/useData.test.js @@ -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' + }) ]) }) }) diff --git a/src/lib/index.jsx b/src/lib/index.jsx index 0b8940c..4836cec 100644 --- a/src/lib/index.jsx +++ b/src/lib/index.jsx @@ -81,7 +81,8 @@ const listboxRules = PropTypes.oneOfType([ ]).isRequired, searchType: PropTypes.oneOf(searchTypes), displayField: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - }) + }), + PropTypes.func ]) Turnstone.propTypes = {