Skip to content

Commit

Permalink
feat(hooks): Add useClientErrors from cozy-client
Browse files Browse the repository at this point in the history
  • Loading branch information
JF-Cozy committed Oct 24, 2024
1 parent 56650db commit b7f203b
Show file tree
Hide file tree
Showing 5 changed files with 264 additions and 383 deletions.
5 changes: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,8 @@
"svgo": "2.8.0",
"svgstore-cli": "1.3.2",
"url-loader": "1.1.2",
"webpack": "4.39.3"
"webpack": "4.39.3",
"whatwg-fetch": "3.5.0"
},
"dependencies": {
"@babel/runtime": "^7.3.4",
Expand Down Expand Up @@ -210,4 +211,4 @@
"browserslist": [
"extends browserslist-config-cozy"
]
}
}
140 changes: 140 additions & 0 deletions react/hooks/useClientErrors.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
import filter from 'lodash/filter'
import React, { useCallback, useState, useEffect } from 'react'

import { useClient } from 'cozy-client'
import { FetchError } from 'cozy-stack-client'

import QuotaAlert from '../deprecated/QuotaAlert'

/**
* Rendering functions for client fetch errors by status code
*
* @private
* @type {object}
*/
const byHttpStatus = {
413: QuotaError // <QuotaError />
}

/**
* Display for a quota error from the client
*
* @see QuotaAlert
* @private
* @param {object} props - Props
* @param {Function} props.dismiss - remove the error from the stack to display
* @returns {React.ReactElement}
*/
function QuotaError({ dismiss }) {
return <QuotaAlert onClose={dismiss} />
}

/**
* Returns the handler for an error
*
* @param {import("../types").ClientError} error - The error
* @returns {Function|null} React Component
*/
function getErrorComponent(error) {
if (error instanceof Response || error instanceof FetchError) {
const status = error.status || ''
return byHttpStatus[status] || null
}
return null
}

/**
* Renders a stack of errors
*
* @private
* @see ClientErrors
* @param {import("../types").ClientError[]} errorStack - array of errors/exceptions
* @param {Function} setErrorStack - mutates the array of errors
* @returns {Array<React.ReactElement>} React rendering
*/
function renderErrors(errorStack, setErrorStack) {
const errors = errorStack.map((error, key) => {
const Component = getErrorComponent(error)
if (Component) {
const dismiss = () =>
setErrorStack(stack => filter(stack, e => e !== error))
return <Component key={key} error={error} dismiss={dismiss} />
} else {
return null
}
})
return errors
}

/**
* Manages the client errors and allows to display them
*
* Returns a `ClientErrors` React component that takes care
* of how to display cozy-client errors (probably displaying a modal)
*
* Only Quota Errors are managed for now.
*
* @example
* ```
* const App = () => {
* const { ClientErrors } = useClientErrors()
*
* return <Layout>
* <h1>My app</h1>
* <ClientErrors />
* </Layout>
* }
* ```
*
* @param {object} [props] - Props
* @param {boolean} [props.handleExceptions] - should cozy-client directly handle errors before forwarding them to the caller?
* @returns {{ClientErrors: Function}} React component
*/
export default function useClientErrors({ handleExceptions = true } = {}) {
const client = useClient()
const [errorStack, setErrorStack] = useState([])

/**
* Handle client errors, add them to the error stack
*
* @param {import("../types").ClientError} error -
* @returns {boolean} true if the error was manager, false otherwise
*/
const handleError = useCallback(
error => {
// `isErrorManaged` is there to avoid the same error to be added twice
// once the error has been added once, the `isErrorManaged`is set to true
// and any future push is ignored
if (error.isErrorManaged) return false

const isManageable = !!getErrorComponent(error)
if (isManageable) {
error.isErrorManaged = true
setErrorStack(stack => stack.concat(error))
return true
} else {
error.isErrorManaged = false
return false
}
},
[setErrorStack]
)

useEffect(() => {
if (handleExceptions) {
client.on('error', handleError)
return () => client.removeListener('error', handleError)
} else {
return undefined
}
}, [client, handleError, handleExceptions])

// @ts-ignore
const ClientErrors = useCallback(
() => renderErrors(errorStack, setErrorStack),
[errorStack, setErrorStack]
)
// @ts-ignore
ClientErrors.displayName = 'ClientErrors'
return { ClientErrors }
}
102 changes: 102 additions & 0 deletions react/hooks/useClientErrors.spec.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { renderHook, act } from '@testing-library/react-hooks'
import { shallow } from 'enzyme'
import React from 'react'

import { CozyProvider } from 'cozy-client'
import { FetchError } from 'cozy-stack-client'

import useClientErrors from './useClientErrors'

function createCozyClient() {
return {
on: jest.fn(),
removeListener: jest.fn()
}
}

function createWrapper(client = createCozyClient()) {
function Wrapper({ children }) {
return <CozyProvider client={client}>{children}</CozyProvider>
}
return Wrapper
}

function renderWrappedHook(client) {
const wrapper = createWrapper(client)
return renderHook(() => useClientErrors(), { wrapper })
}

function wrappedShallow(tree, client) {
return shallow(tree, { wrappingComponent: createWrapper(client) })
}

describe('useClientErrors', () => {
it('registers an `error` handler in client', done => {
const client = createCozyClient()
client.on.mockImplementation(name => name === 'error' && done())
renderWrappedHook(client)
})

describe('renderErrors', () => {
it('returns a function', () => {
const { result } = renderWrappedHook()
const { ClientErrors } = result.current
expect(ClientErrors).toBeInstanceOf(Function)
})

it('displays nothing by default', () => {
const { result } = renderWrappedHook()
const { ClientErrors } = result.current
const node = wrappedShallow(<ClientErrors />)
expect(node).toHaveLength(0)
})

describe('for quota errors', () => {
const findQuotaAlert = node => {
return node.at(0).dive()
}
const setup = async () => {
const client = createCozyClient()
const response = new Response(null, {
status: 413,
statusText: 'Quota exceeded'
})
const error = new FetchError(
response,
`${response.status} ${response.statusText}`
)

const { result, waitForNextUpdate } = renderWrappedHook(client)
const nextUpdate = waitForNextUpdate()

act(() => {
const handler = client.on.mock.calls[0][1]
handler(error)
})

await nextUpdate
const { ClientErrors } = result.current
const node = wrappedShallow(<ClientErrors />, client)
return { node, result, client }
}

it('displays a a QuotaAlert', async () => {
const { node } = await setup()
expect(node).toHaveLength(1)
expect(findQuotaAlert(node).type().name).toEqual('QuotaAlert')
})

it('can be dismissed', async () => {
const { node, result, client } = await setup()
const quotaAlert = findQuotaAlert(node)
const onClose = quotaAlert.props().onClose
act(() => onClose())

// re-render ClientErrors returned by the hook
const { ClientErrors } = result.current
const updatedNode = wrappedShallow(<ClientErrors />, client)
expect(updatedNode.at(0).length).toBe(0)
})
})
})
})
4 changes: 3 additions & 1 deletion test/jestsetup.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import '@testing-library/jest-dom/extend-expect'
import { configure, mount, shallow } from 'enzyme'
import Adapter from 'enzyme-adapter-react-16'
import fetch from 'whatwg-fetch'

configure({ adapter: new Adapter() })

global.fetch = fetch
global.mount = mount
global.shallow = shallow

Expand All @@ -17,7 +19,7 @@ const isDeprecatedLifecycleWarning = (msg, componentName) => {
}

const ignoreOnConditions = (originalWarn, ignoreConditions) => {
return function(...args) {
return function (...args) {
const msg = args[0]
if (ignoreConditions.some(condition => condition(msg))) {
return
Expand Down
Loading

0 comments on commit b7f203b

Please sign in to comment.