Add useSWR error handling, errorMessage prop and errorbox component

This commit is contained in:
Tom Southall
2022-02-25 00:26:44 +00:00
parent 96525d8b6d
commit 3a8b0ba4ff
15 changed files with 119 additions and 17 deletions

View File

@@ -59,6 +59,7 @@ const App = () => {
debounceWait={250}
defaultListbox={defaultListbox}
defaultListboxIsImmutable={false}
errorMessage={'Sorry, something has broken!'}
id='autocomplete'
listbox={listbox}
listboxIsImmutable={true}

View File

@@ -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;

View File

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

View File

@@ -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'

View File

@@ -14,6 +14,12 @@ export const setItems = (items) => {
}
}
export const setItemsError = () => {
return {
type: types.SET_ITEMS_ERROR
}
}
export const clear = () => {
return {
type: types.CLEAR

View File

@@ -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({

View File

@@ -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 && (
<div
className={customStyles.clearButton}
style={defaultStyles.clearButton}
@@ -240,6 +244,10 @@ export default function Container(props) {
noItemsMessage={noItemsMessage}
/>
)}
{isErrorExpanded && (
<Errorbox id={errorboxId} errorMessage={errorMessage} />
)}
</div>
</React.Fragment>
)

View File

@@ -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(),

View File

@@ -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 (
<div id={id} className={customStyles.errorbox} style={defaultStyles.listbox}>
<div className={customStyles.errorMessage}>{errorMessage}</div>
</div>
)
}

View File

@@ -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()

View File

@@ -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) => {

View File

@@ -11,6 +11,7 @@ const StateContextProvider = (props) => {
const [state, dispatch] = useReducer(reducer, {
query: text,
items,
itemsError: false,
itemsLoaded: false,
highlighted: undef,
selected: undef,

View File

@@ -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,

View File

@@ -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:

View File

@@ -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