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 (
+
+
+
+ )
+}
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"