From 3a8b0ba4ff846863c0746f1c0f60b96675d86f49 Mon Sep 17 00:00:00 2001 From: Tom Southall Date: Fri, 25 Feb 2022 00:26:44 +0000 Subject: [PATCH] Add useSWR error handling, errorMessage prop and errorbox component --- examples/geo/App.jsx | 1 + examples/geo/styles/autocomplete.module.css | 13 +++++--- src/App.jsx | 2 ++ src/lib/actions/actionTypes.js | 1 + src/lib/actions/actions.js | 6 ++++ src/lib/actions/actions.test.js | 7 ++++ src/lib/components/container.jsx | 32 +++++++++++------- src/lib/components/container.test.jsx | 1 + src/lib/components/errorbox.jsx | 15 +++++++++ src/lib/components/hooks/containerEffects.js | 10 +++++- src/lib/components/hooks/useData.js | 2 ++ src/lib/context/state.jsx | 1 + src/lib/index.jsx | 1 + src/lib/reducers/reducer.js | 9 +++++ src/lib/reducers/reducer.test.js | 35 ++++++++++++++++++++ 15 files changed, 119 insertions(+), 17 deletions(-) create mode 100644 src/lib/components/errorbox.jsx diff --git a/examples/geo/App.jsx b/examples/geo/App.jsx index 98d0d9c..fd4fe8a 100644 --- a/examples/geo/App.jsx +++ b/examples/geo/App.jsx @@ -59,6 +59,7 @@ const App = () => { debounceWait={250} defaultListbox={defaultListbox} defaultListboxIsImmutable={false} + errorMessage={'Sorry, something has broken!'} id='autocomplete' listbox={listbox} listboxIsImmutable={true} diff --git a/examples/geo/styles/autocomplete.module.css b/examples/geo/styles/autocomplete.module.css index 97f2aa9..dd55e6b 100644 --- a/examples/geo/styles/autocomplete.module.css +++ b/examples/geo/styles/autocomplete.module.css @@ -30,7 +30,8 @@ color: black; } -.listbox { +.listbox, +.errorbox { box-sizing: border-box; width: 500px; border-left: 1px solid #ccc; @@ -41,12 +42,16 @@ background: #fff; } -.noItems { - margin: 8em auto; - height: 38px; +.noItems, +.errorMessage { + margin: 2em auto; text-align: center; } +.errorMessage { + color: rgb(175, 0, 0); +} + .groupHeading { text-transform: uppercase; font-size: 0.8em; diff --git a/src/App.jsx b/src/App.jsx index 4b22b3c..50623d1 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -33,6 +33,7 @@ const App = () => { autoFocus={true} clearButton={true} debounceWait={0} + errorMessage={'Houston we have a problem'} listbox={listbox1} listboxIsImmutable={true} maxItems={10} @@ -47,6 +48,7 @@ const App = () => { autoFocus={true} clearButton={true} debounceWait={0} + errorMessage={'Something is broken'} listbox={listbox2} listboxIsImmutable={true} matchText={true} diff --git a/src/lib/actions/actionTypes.js b/src/lib/actions/actionTypes.js index 7c24a7e..49dc566 100644 --- a/src/lib/actions/actionTypes.js +++ b/src/lib/actions/actionTypes.js @@ -1,5 +1,6 @@ export const SET_QUERY = 'SET_QUERY' export const SET_ITEMS = 'SET_ITEMS' +export const SET_ITEMS_ERROR = 'SET_ITEMS_ERROR' export const CLEAR = 'CLEAR' export const SET_HIGHLIGHTED = 'SET_HIGHLIGHTED' export const CLEAR_HIGHLIGHTED = 'CLEAR_HIGHLIGHTED' diff --git a/src/lib/actions/actions.js b/src/lib/actions/actions.js index f2ce858..5b3615d 100644 --- a/src/lib/actions/actions.js +++ b/src/lib/actions/actions.js @@ -14,6 +14,12 @@ export const setItems = (items) => { } } +export const setItemsError = () => { + return { + type: types.SET_ITEMS_ERROR + } +} + export const clear = () => { return { type: types.CLEAR diff --git a/src/lib/actions/actions.test.js b/src/lib/actions/actions.test.js index cd8d179..3e8ef72 100644 --- a/src/lib/actions/actions.test.js +++ b/src/lib/actions/actions.test.js @@ -18,6 +18,13 @@ describe('Actions', () => { }) }) + test('setItemsError returns expected action', () => { + const action = actions.setItemsError() + expect(action).toEqual({ + type: 'SET_ITEMS_ERROR' + }) + }) + test('clear returns expected action', () => { const action = actions.clear() expect(action).toEqual({ diff --git a/src/lib/components/container.jsx b/src/lib/components/container.jsx index 3c6a603..20cf48b 100644 --- a/src/lib/components/container.jsx +++ b/src/lib/components/container.jsx @@ -1,6 +1,7 @@ import React, { useState, useRef, useContext } from 'react' import { StateContext } from '../context/state' import Listbox from './listbox' +import Errorbox from './errorbox' import { useDebounce } from 'use-debounce' import useData from './hooks/useData' import undef from '../utils/undef' @@ -8,6 +9,7 @@ import isUndefined from '../utils/isUndefined' import defaultStyles from './styles/input.styles.js' import { useItemsState, + useItemsError, useAutoFocus, useQueryChange, useHighlight, @@ -33,6 +35,7 @@ export default function Container(props) { defaultListbox, defaultListboxIsImmutable, disabled, + errorMessage, id, listboxIsImmutable, maxItems, @@ -53,6 +56,7 @@ export default function Container(props) { : [{ ...props.listbox, ...{ name: '', ratio: maxItems } }] const listboxId = `${id}-listbox` + const errorboxId = `${id}-errorbox` // Global state from context const { state, dispatch } = useContext(StateContext) @@ -66,6 +70,11 @@ export default function Container(props) { const queryInput = useRef(null) const typeaheadInput = useRef(null) + // Calculated states + const hasClearButton = clearButton && !!state.query + const isExpanded = hasFocus && state.itemsLoaded + const isErrorExpanded = !!props.errorMessage && state.itemsError + // Checks whether or not SWR data is to be treated as immutable const isImmutable = (() => { return listboxIsImmutable && @@ -77,7 +86,7 @@ export default function Container(props) { })() // Hook to retrieve data using SWR - const swrData = useData( + const swrResult = useData( debouncedQuery.toLowerCase(), isImmutable, listbox, @@ -85,10 +94,13 @@ export default function Container(props) { minQueryLength, maxItems, dispatch - ).data + ) // Store retrieved data in global state as state.items - useItemsState(swrData) + useItemsState(swrResult.data) + + // Store retrieved error if there is one + useItemsError(swrResult.error) // Autofocus on render if prop is true useAutoFocus(queryInput, autoFocus) @@ -114,14 +126,6 @@ export default function Container(props) { if (typeof f === 'function') f(queryInput.current.value, highlightedItem) } - const hasClearButton = () => { - return clearButton && !!state.query - } - - const isExpanded = (() => { - return hasFocus && state.itemsLoaded - })() - // Handle different keypresses and call the appropriate action creators const checkKey = (evt) => { switch (evt.keyCode) { @@ -223,7 +227,7 @@ export default function Container(props) { ref={typeaheadInput} /> - {hasClearButton() && ( + {hasClearButton && (
)} + + {isErrorExpanded && ( + + )}
) diff --git a/src/lib/components/container.test.jsx b/src/lib/components/container.test.jsx index ab18e7b..522f38b 100644 --- a/src/lib/components/container.test.jsx +++ b/src/lib/components/container.test.jsx @@ -9,6 +9,7 @@ import { fruits } from '../../data' vi.mock('./listbox', () => ({ default: () => 'Listbox' })) vi.mock('./hooks/containerEffects', () => ({ useItemsState: vi.fn(), + useItemsError: vi.fn(), useAutoFocus: vi.fn(), useQueryChange: vi.fn(), useHighlight: vi.fn(), diff --git a/src/lib/components/errorbox.jsx b/src/lib/components/errorbox.jsx new file mode 100644 index 0000000..e3368a1 --- /dev/null +++ b/src/lib/components/errorbox.jsx @@ -0,0 +1,15 @@ +import React, { useContext } from 'react' +import { StateContext } from '../context/state' +import defaultStyles from './styles/listbox.styles.js' + +export default function Errorbox(props) { + const { id, errorMessage } = props + const { state } = useContext(StateContext) + const { customStyles } = state + + return ( +
+
{errorMessage}
+
+ ) +} diff --git a/src/lib/components/hooks/containerEffects.js b/src/lib/components/hooks/containerEffects.js index 3bd0968..bea01b5 100644 --- a/src/lib/components/hooks/containerEffects.js +++ b/src/lib/components/hooks/containerEffects.js @@ -1,7 +1,7 @@ import { useEffect, useContext } from 'react' import setify from 'setify' // Sets input value without changing cursor position import { StateContext } from '../../context/state' -import { setItems } from '../../actions/actions' +import { setItems, setItemsError } from '../../actions/actions' import isUndefined from '../../utils/isUndefined' import undef from '../../utils/undef' import startsWithCaseInsensitive from '../../utils/startsWithCaseInsensitive' @@ -14,6 +14,14 @@ export const useItemsState = (swrData) => { }, [swrData]) } +export const useItemsError = (error) => { + const { dispatch } = useContext(StateContext) + + useEffect(() => { + if(error) dispatch(setItemsError()) + }, [error]) +} + export const useAutoFocus = (queryInput, autoFocus) => { // TODO: might be able to use autofocus property of input for this - https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autofocus useEffect(() => { if (autoFocus) queryInput.current.focus() diff --git a/src/lib/components/hooks/useData.js b/src/lib/components/hooks/useData.js index 2836ba9..dac08f3 100644 --- a/src/lib/components/hooks/useData.js +++ b/src/lib/components/hooks/useData.js @@ -111,6 +111,8 @@ export const fetcher = (query, listbox, defaultListbox, minQueryLength, maxItems return groups.flat() }) + .catch(() => {throw('Something went wrong')}) + //TODO Put a .catch here https://javascript.info/promise-error-handling } const useData = (query, isImmutable, listbox, defaultListbox, minQueryLength, maxItems) => { diff --git a/src/lib/context/state.jsx b/src/lib/context/state.jsx index 3822828..7b3ac23 100644 --- a/src/lib/context/state.jsx +++ b/src/lib/context/state.jsx @@ -11,6 +11,7 @@ const StateContextProvider = (props) => { const [state, dispatch] = useReducer(reducer, { query: text, items, + itemsError: false, itemsLoaded: false, highlighted: undef, selected: undef, diff --git a/src/lib/index.jsx b/src/lib/index.jsx index 7e5bc3b..83e5e0c 100644 --- a/src/lib/index.jsx +++ b/src/lib/index.jsx @@ -72,6 +72,7 @@ Turnstone.propTypes = { defaultListboxIsImmutable: PropTypes.bool, disabled: PropTypes.bool, displayField: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + errorMessage: PropTypes.string, id: PropTypes.string, listbox: listboxRules.isRequired, listboxIsImmutable: PropTypes.bool, diff --git a/src/lib/reducers/reducer.js b/src/lib/reducers/reducer.js index 0ddaa18..182abec 100644 --- a/src/lib/reducers/reducer.js +++ b/src/lib/reducers/reducer.js @@ -13,6 +13,7 @@ const reducer = (state, action) => { switch (action.type) { case types.SET_QUERY: newState = { + itemsError: false, query: action.query, selected: undef } @@ -29,6 +30,7 @@ const reducer = (state, action) => { case types.SET_ITEMS: newState = { items: action.items, + itemsError: false, highlighted: (action.items.length && state.query.length) ? highlightedItem(0, action.items) : undef @@ -40,10 +42,17 @@ const reducer = (state, action) => { return { query: '', items: [], + itemsError: false, itemsLoaded: false, highlighted: undef, selected: undef } + case types.SET_ITEMS_ERROR: + return { + items: [], + itemsError: true, + itemsLoaded: false + } case types.SET_HIGHLIGHTED: return { highlighted: highlightedItem(action.index, state.items) } case types.CLEAR_HIGHLIGHTED: diff --git a/src/lib/reducers/reducer.test.js b/src/lib/reducers/reducer.test.js index 156b566..e630093 100644 --- a/src/lib/reducers/reducer.test.js +++ b/src/lib/reducers/reducer.test.js @@ -7,6 +7,7 @@ import defaultListbox from '../../../examples/_shared/defaultListbox' describe('SET_QUERY action', () => { test('produces expected new state', () => { const state = { + itemsError: true, query: 'foo', selected: {index: 0, text: 'Foobar'}, props: { @@ -17,6 +18,7 @@ describe('SET_QUERY action', () => { const action = actions.setQuery('bar') expect(reducer(state, action)).toEqual({ + itemsError: false, query: 'bar', selected: undef, props: { @@ -37,6 +39,7 @@ describe('SET_QUERY action', () => { const action = actions.setQuery('') expect(reducer(state, action)).toEqual({ + itemsError: false, query: '', selected: undef, itemsLoaded: false, @@ -56,6 +59,7 @@ describe('SET_QUERY action', () => { const action = actions.setQuery('f') expect(reducer(state, action)).toEqual({ + itemsError: false, query: 'f', selected: undef, itemsLoaded: false, @@ -78,6 +82,7 @@ describe('SET_QUERY action', () => { expect(reducer(state, action)).toEqual({ query: '', selected: undef, + itemsError: false, itemsLoaded: true, props: { minQueryLength: 1, @@ -91,6 +96,7 @@ describe('SET_ITEMS action', () => { test('produces expected new state', () => { const state = { query: 'foo', + itemsError: true, itemsLoaded: false } @@ -105,6 +111,7 @@ describe('SET_ITEMS action', () => { expect(reducer(state, action)).toEqual({ query: 'foo', items, + itemsError: false, itemsLoaded: true, highlighted: { index: 0, text: 'foo' } }) @@ -122,6 +129,7 @@ describe('SET_ITEMS action', () => { expect(reducer(state, action)).toEqual({ query: 'foo', items, + itemsError: false, highlighted: undef }) }) @@ -138,6 +146,7 @@ describe('SET_ITEMS action', () => { expect(reducer(state, action)).toEqual({ query: '', items, + itemsError: false, highlighted: undef }) }) @@ -158,12 +167,36 @@ describe('SET_ITEMS action', () => { expect(reducer(state, action)).toEqual({ query: '', items, + itemsError: false, itemsLoaded: true, highlighted: undef }) }) }) +describe('SET_ITEMS_ERROR action', () => { + test('produces expected new state', () => { + let action + const state = { + items: [ + {text: 'foo'}, + {text: 'foobar'}, + {text: 'foofoo'} + ], + itemsError: false, + itemsLoaded: true + } + + action = actions.setItemsError() + + expect(reducer(state, action)).toEqual({ + items: [], + itemsError: true, + itemsLoaded: false + }) + }) +}) + describe('CLEAR action', () => { test('produces expected new state', () => { let action @@ -174,6 +207,7 @@ describe('CLEAR action', () => { {text: 'foobar'}, {text: 'foofoo'} ], + itemsError: true, itemsLoaded: true, highlighted: { index: 0, text: 'foo' }, selected: {text: 'foo'} @@ -184,6 +218,7 @@ describe('CLEAR action', () => { expect(reducer(state, action)).toEqual({ query: '', items: [], + itemsError: false, itemsLoaded: false, highlighted: undef, selected: undef