mirror of
https://github.com/fergalmoran/turnstone.git
synced 2025-12-26 19:57:37 +00:00
Add useSWR error handling, errorMessage prop and errorbox component
This commit is contained in:
@@ -59,6 +59,7 @@ const App = () => {
|
||||
debounceWait={250}
|
||||
defaultListbox={defaultListbox}
|
||||
defaultListboxIsImmutable={false}
|
||||
errorMessage={'Sorry, something has broken!'}
|
||||
id='autocomplete'
|
||||
listbox={listbox}
|
||||
listboxIsImmutable={true}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -14,6 +14,12 @@ export const setItems = (items) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const setItemsError = () => {
|
||||
return {
|
||||
type: types.SET_ITEMS_ERROR
|
||||
}
|
||||
}
|
||||
|
||||
export const clear = () => {
|
||||
return {
|
||||
type: types.CLEAR
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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(),
|
||||
|
||||
15
src/lib/components/errorbox.jsx
Normal file
15
src/lib/components/errorbox.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -11,6 +11,7 @@ const StateContextProvider = (props) => {
|
||||
const [state, dispatch] = useReducer(reducer, {
|
||||
query: text,
|
||||
items,
|
||||
itemsError: false,
|
||||
itemsLoaded: false,
|
||||
highlighted: undef,
|
||||
selected: undef,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user