diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-04 17:14:25 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | c4a561c333f6f82678efcffef5ce3ed0f8e322f4 (patch) | |
| tree | be22fd77b2eb5d524ac1b967e71a2893ab7df400 /src/components | |
| parent | ce4a18899f24ba89b63ef743476ec0dbf1999ecf (diff) | |
refactor(components): rewrite ContactForm component
* remove `Notice` props to handle it directly inside the form
* replace `sendMail` prop with `onSubmit` prop
* use `useForm` hook to handle fields
Diffstat (limited to 'src/components')
4 files changed, 308 insertions, 254 deletions
diff --git a/src/components/organisms/forms/contact-form/contact-form.module.scss b/src/components/organisms/forms/contact-form/contact-form.module.scss index c106fb1..e222d40 100644 --- a/src/components/organisms/forms/contact-form/contact-form.module.scss +++ b/src/components/organisms/forms/contact-form/contact-form.module.scss @@ -9,7 +9,11 @@ width: 100%; } -.button { - display: block; +.btn { + width: fit-content; margin: var(--spacing-sm) auto 0; } + +.spinner { + margin: var(--spacing-sm) auto; +} diff --git a/src/components/organisms/forms/contact-form/contact-form.stories.tsx b/src/components/organisms/forms/contact-form/contact-form.stories.tsx index 962bfda..46111e1 100644 --- a/src/components/organisms/forms/contact-form/contact-form.stories.tsx +++ b/src/components/organisms/forms/contact-form/contact-form.stories.tsx @@ -8,33 +8,7 @@ export default { title: 'Organisms/Forms', component: ContactForm, argTypes: { - className: { - control: { - type: 'text', - }, - description: 'Set additional classnames to the form wrapper.', - table: { - category: 'Styles', - }, - type: { - name: 'string', - required: false, - }, - }, - Notice: { - control: { - type: null, - }, - description: 'A component to display a success or error message.', - table: { - category: 'Options', - }, - type: { - name: 'function', - required: false, - }, - }, - sendMail: { + onSubmit: { control: { type: null, }, @@ -44,7 +18,7 @@ export default { }, type: { name: 'function', - required: true, + required: false, }, }, }, @@ -55,12 +29,7 @@ const Template: ComponentStory<typeof ContactForm> = (args) => ( ); /** - * Forms Stories - Contact + * ContactForm Stories - Contact */ export const Contact = Template.bind({}); -Contact.args = { - sendMail: async (_data, reset: () => void) => - new Promise(() => { - reset(); - }), -}; +Contact.args = {}; diff --git a/src/components/organisms/forms/contact-form/contact-form.test.tsx b/src/components/organisms/forms/contact-form/contact-form.test.tsx index 0e2685e..b68146f 100644 --- a/src/components/organisms/forms/contact-form/contact-form.test.tsx +++ b/src/components/organisms/forms/contact-form/contact-form.test.tsx @@ -1,53 +1,164 @@ import { describe, expect, it } from '@jest/globals'; +import { userEvent } from '@testing-library/user-event'; import { render, screen as rtlScreen } from '../../../../../tests/utils'; -import { ContactForm } from './contact-form'; - -const props = { - sendMail: async () => { - /** Do nothing. */ - }, -}; +import { ContactForm, type ContactFormData } from './contact-form'; describe('ContactForm', () => { - it('renders a contact form', () => { - render(<ContactForm {...props} />); - expect( - rtlScreen.getByRole('form', { name: 'Contact form' }) - ).toBeInTheDocument(); - }); + const user = userEvent.setup(); + + it('renders the form fields with a submit button', () => { + const label = 'Contact form'; + render(<ContactForm aria-label={label} />); - it('renders a name field', () => { - render(<ContactForm {...props} />); + expect(rtlScreen.getByRole('form')).toHaveAccessibleName(label); expect( rtlScreen.getByRole('textbox', { name: /^Name:/ }) ).toBeInTheDocument(); - }); - - it('renders an email field', () => { - render(<ContactForm {...props} />); expect( rtlScreen.getByRole('textbox', { name: /^Email:/ }) ).toBeInTheDocument(); - }); - - it('renders an object field', () => { - render(<ContactForm {...props} />); expect( rtlScreen.getByRole('textbox', { name: /^Object:/ }) ).toBeInTheDocument(); - }); - - it('renders a message field', () => { - render(<ContactForm {...props} />); expect( rtlScreen.getByRole('textbox', { name: /^Message:/ }) ).toBeInTheDocument(); - }); - - it('renders a submit button', () => { - render(<ContactForm {...props} />); expect( rtlScreen.getByRole('button', { name: /^Send/ }) ).toBeInTheDocument(); }); + + /* eslint-disable max-statements */ + it('can submit the form', async () => { + const onSubmit = jest.fn((_data: ContactFormData) => undefined); + const values: ContactFormData = { + email: 'Camryn.Hegmann23@gmail.com', + message: 'Nulla eveniet tempora aliquid.', + name: 'Erick82', + object: 'sequi nobis unde', + }; + + render(<ContactForm onSubmit={onSubmit} />); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + expect(onSubmit).not.toHaveBeenCalled(); + + await user.type( + rtlScreen.getByRole('textbox', { name: /^Name:/ }), + values.name + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Email:/ }), + values.email + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Object:/ }), + values.object + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Message:/ }), + values.message + ); + await user.click(rtlScreen.getByRole('button', { name: /^Send/ })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith(values); + }); + /* eslint-enable max-statements */ + + /* eslint-disable max-statements */ + it('can submit the form and inform user on success', async () => { + const successMsg = 'Mail has been sent.'; + const onSubmit = jest.fn((_data: ContactFormData) => { + return { + messages: { success: successMsg }, + validator: () => true, + }; + }); + const values: ContactFormData = { + email: 'Camryn.Hegmann23@gmail.com', + message: 'Nulla eveniet tempora aliquid.', + name: 'Erick82', + object: 'sequi nobis unde', + }; + + render(<ContactForm onSubmit={onSubmit} />); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(4); + + expect(onSubmit).not.toHaveBeenCalled(); + + await user.type( + rtlScreen.getByRole('textbox', { name: /^Name:/ }), + values.name + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Email:/ }), + values.email + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Object:/ }), + values.object + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Message:/ }), + values.message + ); + await user.click(rtlScreen.getByRole('button', { name: /^Send/ })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith(values); + expect(rtlScreen.getByText(successMsg)).toBeInTheDocument(); + }); + /* eslint-enable max-statements */ + + /* eslint-disable max-statements */ + it('can abort submit and inform user on failure', async () => { + const errorMsg = 'An error occurred.'; + const onSubmit = jest.fn((_data: ContactFormData) => { + return { + messages: { error: errorMsg }, + validator: () => false, + }; + }); + const values: ContactFormData = { + email: 'Camryn.Hegmann23@gmail.com', + message: 'Nulla eveniet tempora aliquid.', + name: 'Erick82', + object: 'sequi nobis unde', + }; + + render(<ContactForm onSubmit={onSubmit} />); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(4); + + expect(onSubmit).not.toHaveBeenCalled(); + + await user.type( + rtlScreen.getByRole('textbox', { name: /^Name:/ }), + values.name + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Email:/ }), + values.email + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Object:/ }), + values.object + ); + await user.type( + rtlScreen.getByRole('textbox', { name: /^Message:/ }), + values.message + ); + await user.click(rtlScreen.getByRole('button', { name: /^Send/ })); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith(values); + expect(rtlScreen.getByText(errorMsg)).toBeInTheDocument(); + }); + /* eslint-enable max-statements */ }); diff --git a/src/components/organisms/forms/contact-form/contact-form.tsx b/src/components/organisms/forms/contact-form/contact-form.tsx index ed23aad..8fea16d 100644 --- a/src/components/organisms/forms/contact-form/contact-form.tsx +++ b/src/components/organisms/forms/contact-form/contact-form.tsx @@ -1,16 +1,22 @@ -/* eslint-disable max-statements */ -import { - type ChangeEvent, - type FC, - type FormEvent, - type ReactNode, - useState, - useCallback, - useMemo, -} from 'react'; +import { type FC, useCallback } from 'react'; import { useIntl } from 'react-intl'; -import { useBoolean } from '../../../../utils/hooks'; -import { Button, Form, Input, Label, Spinner, TextArea } from '../../../atoms'; +import type { Nullable } from '../../../../types'; +import { + type FormSubmitHandler, + useForm, + type FormSubmitStatus, + type FormSubmitMessages, +} from '../../../../utils/hooks'; +import { + Button, + Form, + type FormProps, + Input, + Label, + Spinner, + TextArea, + Notice, +} from '../../../atoms'; import { LabelledField } from '../../../molecules'; import styles from './contact-form.module.scss'; @@ -21,19 +27,13 @@ export type ContactFormData = { object: string; }; -export type ContactFormProps = { - /** - * Set additional classnames to the form wrapper. - */ - className?: string; - /** - * Pass a component to print a success/error message. - */ - Notice?: ReactNode; +export type ContactFormSubmit = FormSubmitHandler<ContactFormData>; + +export type ContactFormProps = Omit<FormProps, 'children' | 'onSubmit'> & { /** - * A callback function to send mail. + * A callback function to handle form submit. */ - sendMail: (data: ContactFormData, reset: () => void) => Promise<void>; + onSubmit?: ContactFormSubmit; }; /** @@ -43,92 +43,29 @@ export type ContactFormProps = { */ export const ContactForm: FC<ContactFormProps> = ({ className = '', - Notice, - sendMail, + onSubmit, + ...props }) => { const formClass = `${styles.form} ${className}`; const intl = useIntl(); - const emptyForm: ContactFormData = useMemo(() => { - return { - email: '', - message: '', - name: '', - object: '', - }; - }, []); - const [data, setData] = useState(emptyForm); - const { - activate: activateNotice, - deactivate: deactivateNotice, - state: isSubmitting, - } = useBoolean(false); - - /** - * Reset all the form fields. - */ - const resetForm = useCallback(() => { - setData(emptyForm); - deactivateNotice(); - }, [deactivateNotice, emptyForm]); + const { messages, submit, submitStatus, update, values } = + useForm<ContactFormData>({ + initialValues: + /* The order matter: it will be reused to generate the fields in the right + * order. */ + { + name: '', + email: '', + object: '', + message: '', + }, + submitHandler: onSubmit, + }); - const updateForm = useCallback( - (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { - switch (e.target.name) { - case 'email': - setData((prevData) => { - return { ...prevData, email: e.target.value }; - }); - break; - case 'message': - setData((prevData) => { - return { ...prevData, message: e.target.value }; - }); - break; - case 'name': - setData((prevData) => { - return { ...prevData, name: e.target.value }; - }); - break; - case 'object': - setData((prevData) => { - return { ...prevData, object: e.target.value }; - }); - break; - default: - break; - } - }, - [] - ); - - const formName = intl.formatMessage({ - defaultMessage: 'Contact form', - description: 'ContactForm: form accessible name', - id: 'HFdzae', - }); - - const nameLabel = intl.formatMessage({ - defaultMessage: 'Name:', - description: 'ContactForm: name label', - id: '1dCuCx', - }); - - const emailLabel = intl.formatMessage({ - defaultMessage: 'Email:', - description: 'ContactForm: email label', - id: 'w4B5PA', - }); - - const objectLabel = intl.formatMessage({ - defaultMessage: 'Object:', - description: 'ContactForm: object label', - id: 's8/tyz', - }); - - const messageLabel = intl.formatMessage({ - defaultMessage: 'Message:', - description: 'ContactForm: message label', - id: 'yN5P+m', + const btnLabel = intl.formatMessage({ + defaultMessage: 'Send', + description: 'ContactForm: send button', + id: 'VkAnvv', }); const loadingMsg = intl.formatMessage({ @@ -137,92 +74,125 @@ export const ContactForm: FC<ContactFormProps> = ({ id: 'xaqaYQ', }); - const submitHandler = useCallback( - async (e: FormEvent) => { - e.preventDefault(); - activateNotice(); - await sendMail(data, resetForm).then(() => deactivateNotice()); + const renderFields = useCallback(() => { + const entries = Object.entries(values) as [ + keyof ContactFormData, + ContactFormData[keyof ContactFormData], + ][]; + const labels = { + email: intl.formatMessage({ + defaultMessage: 'Email:', + description: 'ContactForm: email label', + id: 'w4B5PA', + }), + message: intl.formatMessage({ + defaultMessage: 'Message:', + description: 'ContactForm: message label', + id: 'yN5P+m', + }), + name: intl.formatMessage({ + defaultMessage: 'Name:', + description: 'ContactForm: name label', + id: '1dCuCx', + }), + object: intl.formatMessage({ + defaultMessage: 'Object:', + description: 'ContactForm: object label', + id: 's8/tyz', + }), + }; + + return entries.map(([field, value]) => { + const isRequired = field !== 'object'; + const inputType = field === 'email' ? 'email' : 'text'; + + return ( + <LabelledField + className={styles.field} + field={ + field === 'message' ? ( + <TextArea + id={field} + isRequired + name={field} + onChange={update} + value={value} + /> + ) : ( + <Input + id={field} + isRequired={isRequired} + name={field} + onChange={update} + type={inputType} + value={value} + /> + ) + } + key={field} + label={ + <Label htmlFor={field} isRequired={isRequired}> + {labels[field]} + </Label> + } + /> + ); + }); + }, [values, intl, update]); + + const renderNotice = useCallback( + ( + currentStatus: FormSubmitStatus, + msg: Nullable<Partial<FormSubmitMessages>> + ) => { + switch (currentStatus) { + case 'FAILED': + return msg?.error ? ( + <Notice + // eslint-disable-next-line react/jsx-no-literals + kind="error" + > + {msg.error} + </Notice> + ) : null; + case 'PENDING': + return ( + <Notice + // eslint-disable-next-line react/jsx-no-literals + kind="info" + > + <Spinner className={styles.spinner}>{loadingMsg}</Spinner> + </Notice> + ); + case 'SUCCEEDED': + return msg?.success ? ( + <Notice + // eslint-disable-next-line react/jsx-no-literals + kind="success" + > + {msg.success} + </Notice> + ) : null; + default: + return null; + } }, - [activateNotice, data, deactivateNotice, resetForm, sendMail] + [loadingMsg] ); return ( - <Form aria-label={formName} className={formClass} onSubmit={submitHandler}> - <LabelledField - className={styles.field} - field={ - <Input - id="contact-name" - isRequired - name="name" - onChange={updateForm} - type="text" - value={data.name} - /> - } - label={ - <Label htmlFor="contact-name" isRequired> - {nameLabel} - </Label> - } - /> - <LabelledField - className={styles.field} - field={ - <Input - id="contact-email" - isRequired - name="email" - onChange={updateForm} - type="email" - value={data.email} - /> - } - label={ - <Label htmlFor="contact-email" isRequired> - {emailLabel} - </Label> - } - /> - <LabelledField - className={styles.field} - field={ - <Input - id="contact-object" - name="object" - onChange={updateForm} - type="text" - value={data.object} - /> - } - label={<Label htmlFor="contact-object">{objectLabel}</Label>} - /> - <LabelledField - className={styles.field} - field={ - <TextArea - id="contact-message" - isRequired - name="message" - onChange={updateForm} - value={data.message} - /> - } - label={ - <Label htmlFor="contact-message" isRequired> - {messageLabel} - </Label> - } - /> - <Button type="submit" kind="primary" className={styles.button}> - {intl.formatMessage({ - defaultMessage: 'Send', - description: 'ContactForm: send button', - id: 'VkAnvv', - })} + <Form {...props} className={formClass} onSubmit={submit}> + {renderFields()} + <Button + className={styles.btn} + isLoading={submitStatus === 'PENDING'} + // eslint-disable-next-line react/jsx-no-literals + kind="primary" + type="submit" + > + {btnLabel} </Button> - {isSubmitting ? <Spinner>{loadingMsg}</Spinner> : null} - {Notice} + {renderNotice(submitStatus, messages)} </Form> ); }; |
