Skip to content

Commit

Permalink
Add example 12
Browse files Browse the repository at this point in the history
  • Loading branch information
taschetto committed Mar 31, 2021
1 parent d4d1eaf commit 1bea056
Show file tree
Hide file tree
Showing 14 changed files with 318 additions and 5 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"@hookform/devtools": "^2.2.1",
"@hookform/resolvers": "^1.3.7",
"@material-ui/core": "^4.11.3",
"@material-ui/lab": "^4.0.0-alpha.57",
"@testing-library/jest-dom": "^5.11.4",
"@testing-library/react": "^11.1.0",
"@testing-library/user-event": "^12.1.10",
Expand All @@ -15,6 +16,7 @@
"faker": "^5.5.2",
"http-status": "^1.5.0",
"msw": "^0.28.0",
"notistack": "^1.0.5",
"npm-run-all": "^4.1.5",
"prettier": "^2.2.1",
"ramda": "^0.27.1",
Expand Down
7 changes: 5 additions & 2 deletions src/contexts/AppProviders.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import PropTypes from 'prop-types'
import { CssBaseline } from '@material-ui/core'
import { QueryClientProvider } from 'contexts/QueryClientProvider'
import { ThemeProvider } from 'contexts/ThemeContext'
import { NotificationProvider } from 'contexts/NotificationProvider'

export const AppProviders = ({ children }) => {
return (
<QueryClientProvider>
<ThemeProvider>
<CssBaseline />
{children}
<NotificationProvider>
<CssBaseline />
{children}
</NotificationProvider>
</ThemeProvider>
</QueryClientProvider>
)
Expand Down
38 changes: 38 additions & 0 deletions src/contexts/NotificationProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { SnackbarProvider } from 'notistack'

export const NotificationProvider = ({ children }) => {
return (
<SnackbarProvider
autoHideDuration={NOTIFICATION_TIMEOUT_MS}
maxSnack={NOTIFICATION_MAX_VISIBLE}
anchorOrigin={{
vertical: NOTIFICATION_POSITION_VERTICAL,
horizontal: NOTIFICATION_POSITION_HORIZONTAL,
}}>
{children}
</SnackbarProvider>
)
}

const NOTIFICATION_TIMEOUT_MS = 4 * 1000 // 4 seconds

/**
* How many notifications will be visible at the same time.
* @constant
* @type {number}
*/
const NOTIFICATION_MAX_VISIBLE = 3

/**
* Notifications vertical anchor origin.
* @constant
* @type {string}
*/
const NOTIFICATION_POSITION_VERTICAL = 'top'

/**
* Notifications horizontal anchor origin.
* @constant
* @type {string}
*/
const NOTIFICATION_POSITION_HORIZONTAL = 'center'
23 changes: 23 additions & 0 deletions src/examples/12/Form/Button.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { useFormContext } from 'react-hook-form'
import { Button as MuiButton } from '@material-ui/core'

export const Button = ({
children = 'Submit',
variant = 'outlined',
fullWidth = true,
...props
}) => {
const {
formState: { isSubmitting },
} = useFormContext()

return (
<MuiButton
{...props}
disabled={isSubmitting}
variant={variant}
fullWidth={fullWidth}>
{children}
</MuiButton>
)
}
17 changes: 17 additions & 0 deletions src/examples/12/Form/FormProvider.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import {
FormProvider as ReactHookFormProvider,
useFormContext,
} from 'react-hook-form'
import { DevTool } from '@hookform/devtools'

export const FormProvider = ({ children, ...other }) => (
<ReactHookFormProvider {...other}>
{children}
<DevTools />
</ReactHookFormProvider>
)

const DevTools = () => {
const { control } = useFormContext()
return <DevTool control={control} />
}
36 changes: 36 additions & 0 deletions src/examples/12/Form/Input.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useFormContext, Controller } from 'react-hook-form'
import { TextField } from '@material-ui/core'
import { path } from 'ramda'

export const Input = ({ name, label, defaultValue = '' }) => {
const {
register,
formState: { isSubmitting },
errors,
control,
} = useFormContext()

const disabled = isSubmitting
const helperText = path([name, 'message'], errors)
const error = Boolean(helperText)

return (
<Controller
as={
<TextField
name={name}
label={label}
ref={register}
disabled={disabled}
helperText={helperText}
error={error}
variant='filled'
fullWidth
/>
}
name={name}
control={control}
defaultValue={defaultValue}
/>
)
}
5 changes: 5 additions & 0 deletions src/examples/12/Form/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export { Button } from './Button'
export { Input } from './Input'
export { FormProvider } from './FormProvider'
export { useForm } from './useForm'
export { setAsyncError } from './setAsyncError'
46 changes: 46 additions & 0 deletions src/examples/12/Form/setAsyncError.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { __, compose, forEach, identity, ifElse, includes, path } from 'ramda'
import { BAD_REQUEST, UNPROCESSABLE_ENTITY } from 'http-status'

/**
* A function that receives a `XMLHttpRequest` error object and sets the form
* errors according to the error type.
*
* @callback SetErrorCallback
* @param {Object} error - the raw `XMLHttpRequest` error object.
*/

/**
* Received
* @param {Function} setErrorCallback - The current form `setError` function (returned from `useForm`).
* @param {Function} setFormWideErrorCallback - The current form `setFormWideError` function (returned from `useForm`).
* @returns {SetErrorCallback} callback
*/
export const setAsyncError = (
setErrorCallback,
setFormWideErrorCallback = identity
) =>
ifElse(
isValidationError,
setValidationErrors(setErrorCallback),
setFormWideError(setFormWideErrorCallback)
)

/**
* Checks if the error type equals to "validation", which comprehends HTTP 400 (Bad Request)
* and HTTP 422 (Unprocessable Entity).
*/
const isValidationError = compose(
includes(__, [BAD_REQUEST, UNPROCESSABLE_ENTITY]),
path(['response', 'status'])
)

const setValidationErrors = setErrorCallback =>
compose(
forEach(({ name, message }) =>
setErrorCallback(name, { type: 'manual', message })
),
path(['response', 'data', 'errors'])
)

const setFormWideError = setFormWideErrorCallback =>
compose(setFormWideErrorCallback, path(['response', 'data', 'message']))
32 changes: 32 additions & 0 deletions src/examples/12/Form/useForm.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { useCallback } from 'react'
import { useState } from 'react'
import { useForm as useRHForm } from 'react-hook-form'

export const useForm = (...args) => {
const [formWideError, setFormWideError] = useState('')
const { handleSubmit: RHFhandleSubmit, ...formProps } = useRHForm(...args)

/**
* We need this custom submit handler because, typically, we will use a
* `mutateAsync` function from `react-query` to submit a form. These functions
* will throw any errors (422, 500, for example). Since the error is already
* handled by the mutation itself, we need to prevent this error from leaking.
*
* The original handler is exported as `RHFhandleSubmit`.
*
* @see {@link https://codesandbox.io/s/blissful-ride-wxxhl?file=/src/App.js}
* @see {@link https://codesandbox.io/s/30xos?file=/src/App.js}
*/
const handleSubmit = useCallback(
onSubmit => RHFhandleSubmit(data => onSubmit(data).catch(() => {})),
[RHFhandleSubmit]
)

return {
...formProps,
formWideError,
setFormWideError,
handleSubmit,
RHFhandleSubmit,
}
}
74 changes: 74 additions & 0 deletions src/examples/12/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { yupResolver } from '@hookform/resolvers/yup'
import { Grid, Collapse } from '@material-ui/core'
import { Alert, AlertTitle } from '@material-ui/lab'
import { useCustomMutation } from './useCustomMutation'
import { schema } from './schema'
import { FormProvider, useForm, Input, Button, setAsyncError } from './Form'
import { useSnackbar } from 'notistack'

export default () => {
const { enqueueSnackbar } = useSnackbar()

const { formWideError, setFormWideError, ...formProps } = useForm({
resolver: yupResolver(schema),
})

const { handleSubmit, setError } = formProps

const options = {
onSuccess: () => {
enqueueSnackbar('Created successfully.', {
variant: 'success',
})
},
onMutate: () => setFormWideError(''),
onError: error => {
enqueueSnackbar('Something went wrong.', {
variant: 'error',
})
setAsyncError(setError, setFormWideError)(error)
},
}

const { mutateAsync: created } = useCustomMutation('/created', options)

const { mutateAsync: unprocessableEntity } = useCustomMutation(
'/unprocessable-entity',
options
)

const { mutateAsync: conflict } = useCustomMutation('/conflict', options)

return (
<FormProvider {...formProps}>
<form onSubmit={handleSubmit(created)}>
<Grid container spacing={2}>
<Grid item xs={12}>
<h1>Final version</h1>
</Grid>
<Grid item xs={12}>
<Collapse in={Boolean(formWideError)}>
<Alert severity='error' onClose={() => setFormWideError('')}>
<AlertTitle>An error occurred</AlertTitle>
{formWideError}
</Alert>
</Collapse>
</Grid>
<Grid item xs={4}>
<Input name='firstName' defaultValue='John' label='First name' />
</Grid>
<Grid item xs={4}>
<Input name='lastName' defaultValue='Doe' label='Last name' />
</Grid>
<Grid item xs={4}>
<Button onClick={handleSubmit(created)}>Submit (201)</Button>
<Button onClick={handleSubmit(conflict)}>Submit (409)</Button>
<Button onClick={handleSubmit(unprocessableEntity)}>
Submit (422)
</Button>
</Grid>
</Grid>
</form>
</FormProvider>
)
}
6 changes: 6 additions & 0 deletions src/examples/12/schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import * as yup from 'yup'

export const schema = yup.object().shape({
firstName: yup.string().required(),
lastName: yup.string().required(),
})
5 changes: 5 additions & 0 deletions src/examples/12/useCustomMutation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { instance } from 'api'
import { useMutation } from 'react-query'

export const useCustomMutation = (url, options = {}) =>
useMutation(async () => await instance.post(url), options)
11 changes: 10 additions & 1 deletion src/mocks/browser.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { setupWorker, rest } from 'msw'
import { CREATED, UNPROCESSABLE_ENTITY } from 'http-status'
import { CONFLICT, CREATED, UNPROCESSABLE_ENTITY } from 'http-status'

const DELAY_MS = 1000

Expand All @@ -23,6 +23,15 @@ const handlers = [
})
)
),
rest.post('/conflict', (req, res, ctx) =>
res(
ctx.delay(DELAY_MS),
ctx.status(CONFLICT),
ctx.json({
message: 'A conflict occurred.',
})
)
),
]

export const worker = setupWorker(...handlers)
21 changes: 19 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -1369,6 +1369,16 @@
react-is "^16.8.0 || ^17.0.0"
react-transition-group "^4.4.0"

"@material-ui/lab@^4.0.0-alpha.57":
version "4.0.0-alpha.57"
resolved "https://registry.yarnpkg.com/@material-ui/lab/-/lab-4.0.0-alpha.57.tgz#e8961bcf6449e8a8dabe84f2700daacfcafbf83a"
dependencies:
"@babel/runtime" "^7.4.4"
"@material-ui/utils" "^4.11.2"
clsx "^1.0.4"
prop-types "^15.7.2"
react-is "^16.8.0 || ^17.0.0"

"@material-ui/styles@^4.11.3":
version "4.11.3"
resolved "https://registry.yarnpkg.com/@material-ui/styles/-/styles-4.11.3.tgz#1b8d97775a4a643b53478c895e3f2a464e8916f2"
Expand Down Expand Up @@ -3142,7 +3152,7 @@ cliui@^7.0.2:
strip-ansi "^6.0.0"
wrap-ansi "^7.0.0"

clsx@^1.0.4:
clsx@^1.0.4, clsx@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188"

Expand Down Expand Up @@ -5110,7 +5120,7 @@ hmac-drbg@^1.0.1:
minimalistic-assert "^1.0.0"
minimalistic-crypto-utils "^1.0.1"

hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.2:
hoist-non-react-statics@^3.1.0, hoist-non-react-statics@^3.3.0, hoist-non-react-statics@^3.3.2:
version "3.3.2"
resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45"
dependencies:
Expand Down Expand Up @@ -7082,6 +7092,13 @@ normalize-url@^3.0.0:
version "3.3.0"
resolved "https://registry.yarnpkg.com/normalize-url/-/normalize-url-3.3.0.tgz#b2e1c4dc4f7c6d57743df733a4f5978d18650559"

notistack@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/notistack/-/notistack-1.0.5.tgz#239d5888105c89a9a7f26d75a07d279446dc1624"
dependencies:
clsx "^1.1.0"
hoist-non-react-statics "^3.3.0"

npm-run-all@^4.1.5:
version "4.1.5"
resolved "https://registry.yarnpkg.com/npm-run-all/-/npm-run-all-4.1.5.tgz#04476202a15ee0e2e214080861bff12a51d98fba"
Expand Down

1 comment on commit 1bea056

@vercel
Copy link

@vercel vercel bot commented on 1bea056 Mar 31, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.