From 1bea056d3c58e3c30d5ac8e691038311cda6de2a Mon Sep 17 00:00:00 2001 From: Guilherme Taschetto Date: Wed, 31 Mar 2021 16:22:27 -0300 Subject: [PATCH] Add example 12 --- package.json | 2 + src/contexts/AppProviders.js | 7 ++- src/contexts/NotificationProvider.js | 38 ++++++++++++++ src/examples/12/Form/Button.js | 23 +++++++++ src/examples/12/Form/FormProvider.js | 17 ++++++ src/examples/12/Form/Input.js | 36 +++++++++++++ src/examples/12/Form/index.js | 5 ++ src/examples/12/Form/setAsyncError.js | 46 +++++++++++++++++ src/examples/12/Form/useForm.js | 32 ++++++++++++ src/examples/12/index.js | 74 +++++++++++++++++++++++++++ src/examples/12/schema.js | 6 +++ src/examples/12/useCustomMutation.js | 5 ++ src/mocks/browser.js | 11 +++- yarn.lock | 21 +++++++- 14 files changed, 318 insertions(+), 5 deletions(-) create mode 100644 src/contexts/NotificationProvider.js create mode 100644 src/examples/12/Form/Button.js create mode 100644 src/examples/12/Form/FormProvider.js create mode 100644 src/examples/12/Form/Input.js create mode 100644 src/examples/12/Form/index.js create mode 100644 src/examples/12/Form/setAsyncError.js create mode 100644 src/examples/12/Form/useForm.js create mode 100644 src/examples/12/index.js create mode 100644 src/examples/12/schema.js create mode 100644 src/examples/12/useCustomMutation.js diff --git a/package.json b/package.json index d6ce188..6205eaf 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/contexts/AppProviders.js b/src/contexts/AppProviders.js index 8b7a9ef..1683d07 100644 --- a/src/contexts/AppProviders.js +++ b/src/contexts/AppProviders.js @@ -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 ( - - {children} + + + {children} + ) diff --git a/src/contexts/NotificationProvider.js b/src/contexts/NotificationProvider.js new file mode 100644 index 0000000..1f90f03 --- /dev/null +++ b/src/contexts/NotificationProvider.js @@ -0,0 +1,38 @@ +import { SnackbarProvider } from 'notistack' + +export const NotificationProvider = ({ children }) => { + return ( + + {children} + + ) +} + +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' diff --git a/src/examples/12/Form/Button.js b/src/examples/12/Form/Button.js new file mode 100644 index 0000000..51a92e9 --- /dev/null +++ b/src/examples/12/Form/Button.js @@ -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 ( + + {children} + + ) +} diff --git a/src/examples/12/Form/FormProvider.js b/src/examples/12/Form/FormProvider.js new file mode 100644 index 0000000..699e6d1 --- /dev/null +++ b/src/examples/12/Form/FormProvider.js @@ -0,0 +1,17 @@ +import { + FormProvider as ReactHookFormProvider, + useFormContext, +} from 'react-hook-form' +import { DevTool } from '@hookform/devtools' + +export const FormProvider = ({ children, ...other }) => ( + + {children} + + +) + +const DevTools = () => { + const { control } = useFormContext() + return +} diff --git a/src/examples/12/Form/Input.js b/src/examples/12/Form/Input.js new file mode 100644 index 0000000..63deca3 --- /dev/null +++ b/src/examples/12/Form/Input.js @@ -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 ( + + } + name={name} + control={control} + defaultValue={defaultValue} + /> + ) +} diff --git a/src/examples/12/Form/index.js b/src/examples/12/Form/index.js new file mode 100644 index 0000000..5caec50 --- /dev/null +++ b/src/examples/12/Form/index.js @@ -0,0 +1,5 @@ +export { Button } from './Button' +export { Input } from './Input' +export { FormProvider } from './FormProvider' +export { useForm } from './useForm' +export { setAsyncError } from './setAsyncError' diff --git a/src/examples/12/Form/setAsyncError.js b/src/examples/12/Form/setAsyncError.js new file mode 100644 index 0000000..646ebee --- /dev/null +++ b/src/examples/12/Form/setAsyncError.js @@ -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'])) diff --git a/src/examples/12/Form/useForm.js b/src/examples/12/Form/useForm.js new file mode 100644 index 0000000..e8ecbaf --- /dev/null +++ b/src/examples/12/Form/useForm.js @@ -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, + } +} diff --git a/src/examples/12/index.js b/src/examples/12/index.js new file mode 100644 index 0000000..e1f417f --- /dev/null +++ b/src/examples/12/index.js @@ -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 ( + +
+ + +

Final version

+
+ + + setFormWideError('')}> + An error occurred + {formWideError} + + + + + + + + + + + + + + +
+
+
+ ) +} diff --git a/src/examples/12/schema.js b/src/examples/12/schema.js new file mode 100644 index 0000000..17117cf --- /dev/null +++ b/src/examples/12/schema.js @@ -0,0 +1,6 @@ +import * as yup from 'yup' + +export const schema = yup.object().shape({ + firstName: yup.string().required(), + lastName: yup.string().required(), +}) diff --git a/src/examples/12/useCustomMutation.js b/src/examples/12/useCustomMutation.js new file mode 100644 index 0000000..b56fdae --- /dev/null +++ b/src/examples/12/useCustomMutation.js @@ -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) diff --git a/src/mocks/browser.js b/src/mocks/browser.js index 968bcc5..80d6b46 100644 --- a/src/mocks/browser.js +++ b/src/mocks/browser.js @@ -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 @@ -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) diff --git a/yarn.lock b/yarn.lock index 3c834ba..1d10b2b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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" @@ -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" @@ -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: @@ -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"