aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/organisms
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-04 17:14:25 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-11 18:15:27 +0100
commitc4a561c333f6f82678efcffef5ce3ed0f8e322f4 (patch)
treebe22fd77b2eb5d524ac1b967e71a2893ab7df400 /src/components/organisms
parentce4a18899f24ba89b63ef743476ec0dbf1999ecf (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/organisms')
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.module.scss8
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.stories.tsx39
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.test.tsx173
-rw-r--r--src/components/organisms/forms/contact-form/contact-form.tsx342
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>
);
};