diff --git a/src/App.tsx b/src/App.tsx index 9f5912f..ff2ed27 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -28,7 +28,7 @@ const App = (): JSX.Element => ( - + diff --git a/src/common/components/Card/CardRow.scss b/src/common/components/Card/CardRow.scss deleted file mode 100644 index 15e2174..0000000 --- a/src/common/components/Card/CardRow.scss +++ /dev/null @@ -1,36 +0,0 @@ -.row-card { - justify-content: center; - align-items: center; - - .wrapper { - width: 100%; - } - - // xs - @media (max-width: 575px) { - } - // sm - @media (min-width: 576px) { - .wrapper { - width: 80%; - } - } - // md - @media (min-width: 768px) { - .wrapper { - width: 60%; - } - } - // lg - @media (min-width: 992px) { - .wrapper { - width: 50%; - } - } - // xl - @media (min-width: 1200px) { - .wrapper { - width: 40%; - } - } -} diff --git a/src/common/components/Card/CardRow.tsx b/src/common/components/Card/CardRow.tsx index e6363f2..0ec1097 100644 --- a/src/common/components/Card/CardRow.tsx +++ b/src/common/components/Card/CardRow.tsx @@ -1,16 +1,15 @@ -import { IonRow } from '@ionic/react'; -import { PropsWithChildren } from 'react'; +import { IonCol, IonGrid, IonRow } from '@ionic/react'; +import { ComponentPropsWithoutRef } from 'react'; import classNames from 'classnames'; -import './CardRow.scss'; import { BaseComponentProps } from '../types'; /** * Properties for the `CardRow` component. * @see {@link BaseComponentProps} - * @see {@link PropsWithChildren} + * @see {@link IonCol} */ -interface CardRowProps extends BaseComponentProps, PropsWithChildren {} +interface CardRowProps extends BaseComponentProps, ComponentPropsWithoutRef {} /** * The `CardRow` component displays an `IonCard` (or other Card component) @@ -21,11 +20,27 @@ interface CardRowProps extends BaseComponentProps, PropsWithChildren {} * @param {CardRowProps} props - Component properties. * @returns JSX */ -const CardRow = ({ children, className, testid = 'row-card' }: CardRowProps): JSX.Element => { +const CardRow = ({ + className, + testid = 'row-card', + sizeMd = '8', + offsetMd = '2', + sizeLg = '6', + offsetLg = '3', + ...colProps +}: CardRowProps): JSX.Element => { return ( - -
{children}
-
+ + + + + ); }; diff --git a/src/common/components/Icon/Icon.tsx b/src/common/components/Icon/Icon.tsx index 95cd584..2e93681 100644 --- a/src/common/components/Icon/Icon.tsx +++ b/src/common/components/Icon/Icon.tsx @@ -11,6 +11,7 @@ import { faMapLocationDot, faPenToSquare, faPhone, + faPlus, faSignOutAlt, faTrash, faTriangleExclamation, @@ -46,6 +47,7 @@ export enum IconName { MapLocationDot = 'map_location_dot', PenToSquare = 'pen_to_square', Phone = 'phone', + Plus = 'plus', SignOut = 'sign_out', Trash = 'trash', TriangleExclamation = 'triangle_exclamation', @@ -66,6 +68,7 @@ const icons: Record = { map_location_dot: faMapLocationDot, pen_to_square: faPenToSquare, phone: faPhone, + plus: faPlus, sign_out: faSignOutAlt, trash: faTrash, triangle_exclamation: faTriangleExclamation, diff --git a/src/common/components/Router/TabNavigation.tsx b/src/common/components/Router/TabNavigation.tsx index b8ed8e2..bfc3869 100644 --- a/src/common/components/Router/TabNavigation.tsx +++ b/src/common/components/Router/TabNavigation.tsx @@ -8,6 +8,7 @@ import HomePage from 'pages/Home/HomePage'; import UserDetailPage from 'pages/Users/components/UserDetail/UserDetailPage'; import UserListPage from 'pages/Users/components/UserList/UserListPage'; import UserEditPage from 'pages/Users/components/UserEdit/UserEditPage'; +import UserAddPage from 'pages/Users/components/UserAdd/UserAddPage'; import AccountPage from 'pages/Account/AccountPage'; /** @@ -45,6 +46,9 @@ const TabNavigation = (): JSX.Element => { + + + diff --git a/src/pages/Auth/SignIn/components/SignInForm.scss b/src/pages/Auth/SignIn/components/SignInForm.scss index af888d5..c78d3fb 100644 --- a/src/pages/Auth/SignIn/components/SignInForm.scss +++ b/src/pages/Auth/SignIn/components/SignInForm.scss @@ -10,16 +10,6 @@ ion-button.button-submit { margin-top: 2rem; } - - .row-message { - margin: 1rem 0; - - &.row-card { - .wrapper { - width: 100% !important; - } - } - } } .form-signin-popover { diff --git a/src/pages/Auth/SignIn/components/SignInForm.tsx b/src/pages/Auth/SignIn/components/SignInForm.tsx index 9a7fe23..53a8171 100644 --- a/src/pages/Auth/SignIn/components/SignInForm.tsx +++ b/src/pages/Auth/SignIn/components/SignInForm.tsx @@ -9,7 +9,6 @@ import { BaseComponentProps } from 'common/components/types'; import { useSignIn } from '../api/useSignIn'; import { useProgress } from 'common/hooks/useProgress'; import Input from 'common/components/Input/Input'; -import CardRow from 'common/components/Card/CardRow'; import ErrorCard from 'common/components/Card/ErrorCard'; import Icon, { IconName } from 'common/components/Icon/Icon'; import HeaderRow from 'common/components/Text/HeaderRow'; @@ -51,9 +50,11 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX return (
{error && ( - - - + )} diff --git a/src/pages/Users/api/useCreateUser.ts b/src/pages/Users/api/useCreateUser.ts new file mode 100644 index 0000000..f68b1f5 --- /dev/null +++ b/src/pages/Users/api/useCreateUser.ts @@ -0,0 +1,63 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { useAxios } from 'common/hooks/useAxios'; +import { useConfig } from 'common/hooks/useConfig'; +import { User } from 'common/models/user'; +import { QueryKey } from 'common/utils/constants'; + +/** + * Describes the properties of a `User` needed to create a new `User`. + */ +export type CreateUserDTO = Pick; + +/** + * The `useCreateUser` mutation function variables. + * @param {CreateUserDTO} user - The user to be created. + */ +export type CreateUserVariables = { + user: CreateUserDTO; +}; + +/** + * An API hook which updates a single `User`. Returns a `UseMutationResult` + * object whose `mutate` attribute is a function to create a `User`. + * + * When succesful, the hook updates the cached `User` query data. + * @returns Returns a `UseMutationResult`. + */ +export const useCreateUser = () => { + const axios = useAxios(); + const queryClient = useQueryClient(); + const config = useConfig(); + + /** + * Create a `User`. + * @param {CreateUserVariables} variables - The mutation function variables. + * @returns {Promise} Returns a Promise which resolves to the created + * `User`. + */ + const createUser = async ({ user }: CreateUserVariables): Promise => { + const response = await axios.request({ + method: 'post', + url: `${config.VITE_BASE_URL_API}/users`, + data: user, + }); + + return response.data; + }; + + return useMutation({ + mutationFn: createUser, + onSuccess: (data) => { + // update cached query data + const userId: string = '' + data.id; + queryClient.setQueryData([QueryKey.Users], (cachedUsers) => + cachedUsers ? [...cachedUsers, data] : [data], + ); + queryClient.setQueryData([QueryKey.Users, userId], data); + // you may [also|instead] choose to invalidate certaincached queries, triggering refetch + // queryClient.invalidateQueries({ queryKey: [QueryKey.Users], exact: true }); + // queryClient.invalidateQueries({ queryKey: [QueryKey.Users, userId] }); + }, + }); +}; diff --git a/src/pages/Users/api/useUpdateUser.ts b/src/pages/Users/api/useUpdateUser.ts index 478926e..f77202c 100644 --- a/src/pages/Users/api/useUpdateUser.ts +++ b/src/pages/Users/api/useUpdateUser.ts @@ -7,7 +7,7 @@ import { User } from 'common/models/user'; import { QueryKey } from 'common/utils/constants'; /** - * The`useUpdateUser` mutation function variables. + * The `useUpdateUser` mutation function variables. * @param {User} user - The updated `User` object. */ export type UpdateUserVariables = { @@ -15,7 +15,7 @@ export type UpdateUserVariables = { }; /** - * An API hook which updates a single `user`. Returns a `UseMutationResult` + * An API hook which updates a single `User`. Returns a `UseMutationResult` * object whose `mutate` attribute is a function to udate a `User`. * * When successful, the hook updates the cached `User` query data. diff --git a/src/pages/Users/components/UserAdd/UserAdd.tsx b/src/pages/Users/components/UserAdd/UserAdd.tsx new file mode 100644 index 0000000..45a5a9c --- /dev/null +++ b/src/pages/Users/components/UserAdd/UserAdd.tsx @@ -0,0 +1,77 @@ +import { IonCol, IonGrid, IonRow, useIonRouter } from '@ionic/react'; +import { useState } from 'react'; +import classNames from 'classnames'; + +import { BaseComponentProps } from 'common/components/types'; +import { useCreateUser } from 'pages/Users/api/useCreateUser'; +import { useProgress } from 'common/hooks/useProgress'; +import { useToasts } from 'common/hooks/useToasts'; +import { DismissButton } from 'common/components/Toast/Toast'; +import ErrorCard from 'common/components/Card/ErrorCard'; +import UserForm from '../UserForm/UserForm'; + +/** + * The `UserAdd` component renders a Formik form for creating a `User`. + * @param {BaseComponentProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserAdd = ({ className, testid = 'user-add' }: BaseComponentProps): JSX.Element => { + const [error, setError] = useState(''); + const router = useIonRouter(); + const { mutate: createUser } = useCreateUser(); + const { setProgress } = useProgress(); + const { createToast } = useToasts(); + + const onCancel = () => { + router.goBack(); + }; + + return ( +
+ + + + {error && ( + + )} + + { + setProgress(true); + setError(''); + createUser( + { user: values }, + { + onSuccess: (user) => { + setProgress(false); + setSubmitting(false); + createToast({ + buttons: [DismissButton], + duration: 5000, + message: `${user.name} created`, + }); + router.push(`/tabs/users/${user.id}`, 'forward', 'replace'); + }, + onError(error) { + setProgress(false); + setError(error.message); + setSubmitting(false); + }, + }, + ); + }} + testid={`${testid}-form`} + /> + + + +
+ ); +}; + +export default UserAdd; diff --git a/src/pages/Users/components/UserAdd/UserAddFab.scss b/src/pages/Users/components/UserAdd/UserAddFab.scss new file mode 100644 index 0000000..187c6a0 --- /dev/null +++ b/src/pages/Users/components/UserAdd/UserAddFab.scss @@ -0,0 +1,5 @@ +.fab-user-add { + .icon { + font-size: 1.125rem; + } +} diff --git a/src/pages/Users/components/UserAdd/UserAddFab.tsx b/src/pages/Users/components/UserAdd/UserAddFab.tsx new file mode 100644 index 0000000..4900d40 --- /dev/null +++ b/src/pages/Users/components/UserAdd/UserAddFab.tsx @@ -0,0 +1,35 @@ +import { IonFab, IonFabButton } from '@ionic/react'; +import classNames from 'classnames'; + +import './UserAddFab.scss'; +import { BaseComponentProps } from 'common/components/types'; +import Icon, { IconName } from 'common/components/Icon/Icon'; + +/** + * Properties for the `UserAddFab` component. + */ +interface UserAddFabProps extends BaseComponentProps {} + +/** + * The `UserAddFab` renders an Ionic Floating Action Button, or FAB. + * The button navigates to the create new `User` form when clicked. + * @param {UserAddFabProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserAddFab = ({ className, testid = 'fab-user-add' }: UserAddFabProps): JSX.Element => { + return ( + + + + + + ); +}; + +export default UserAddFab; diff --git a/src/pages/Users/components/UserAdd/UserAddPage.tsx b/src/pages/Users/components/UserAdd/UserAddPage.tsx new file mode 100644 index 0000000..ee45cc3 --- /dev/null +++ b/src/pages/Users/components/UserAdd/UserAddPage.tsx @@ -0,0 +1,35 @@ +import { IonContent, IonPage } from '@ionic/react'; + +import { PropsWithTestId } from 'common/components/types'; +import ProgressProvider from 'common/providers/ProgressProvider'; +import PageHeader from 'common/components/Content/PageHeader'; +import Header from 'common/components/Header/Header'; +import Container from 'common/components/Content/Container'; +import UserAdd from './UserAdd'; + +/** + * The `UserAddPage` component renders a page layout containing a form to + * create new `User`s. + * @param {PropsWithTestId} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserAddPage = ({ testid = 'page-user-add' }: PropsWithTestId): JSX.Element => { + return ( + + +
+ + + + Add User + + + + + + + + ); +}; + +export default UserAddPage; diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx new file mode 100644 index 0000000..05685d5 --- /dev/null +++ b/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import UserAdd from '../UserAdd'; + +describe('UserAdd', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-add'); + + // ASSERT + expect(screen.getByTestId('user-add')).toBeDefined(); + }); +}); diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAddFab.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAddFab.test.tsx new file mode 100644 index 0000000..61a2740 --- /dev/null +++ b/src/pages/Users/components/UserAdd/__tests__/UserAddFab.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import UserAddFab from '../UserAddFab'; + +describe('UserAddFab', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('fab-user-add'); + + // ASSERT + expect(screen.getByTestId('fab-user-add')).toBeDefined(); + }); +}); diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx new file mode 100644 index 0000000..331b560 --- /dev/null +++ b/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import UserAddPage from '../UserAddPage'; + +describe('UserAddPage', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('page-user-add'); + + // ASSERT + expect(screen.getByTestId('page-user-add')).toBeDefined(); + }); +}); diff --git a/src/pages/Users/components/UserEdit/UserEdit.tsx b/src/pages/Users/components/UserEdit/UserEdit.tsx new file mode 100644 index 0000000..9869d2e --- /dev/null +++ b/src/pages/Users/components/UserEdit/UserEdit.tsx @@ -0,0 +1,91 @@ +import { useState } from 'react'; +import { IonCol, IonGrid, IonRow, useIonRouter } from '@ionic/react'; +import classNames from 'classnames'; + +import { BaseComponentProps } from 'common/components/types'; +import { User } from 'common/models/user'; +import { useUpdateUser } from 'pages/Users/api/useUpdateUser'; +import { useProgress } from 'common/hooks/useProgress'; +import { useToasts } from 'common/hooks/useToasts'; +import { DismissButton } from 'common/components/Toast/Toast'; +import ErrorCard from 'common/components/Card/ErrorCard'; +import UserForm from '../UserForm/UserForm'; + +/** + * Properties for the `UserEdit` component. + * @see {@link BaseComponentProps} + */ +interface UserEditProps extends BaseComponentProps { + user: User; +} + +/** + * The `UserEdit` component renders a Formik form for editing a `User`. + * @param {UserEditProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserEdit = ({ className, user, testid = 'user-edit' }: UserEditProps): JSX.Element => { + const router = useIonRouter(); + const [error, setError] = useState(''); + const { mutate: updateUser } = useUpdateUser(); + const { createToast } = useToasts(); + const { setProgress } = useProgress(); + + const onCancel = () => { + router.goBack(); + }; + + return ( +
+ + + + {error && ( + + )} + + { + setProgress(true); + setError(''); + updateUser( + { user: { ...user, ...values } }, + { + onSuccess: (user) => { + setProgress(false); + setSubmitting(false); + createToast({ + buttons: [DismissButton], + duration: 5000, + message: `${user.name} updated`, + }); + if (router.canGoBack()) { + router.goBack(); + } else { + router.push(`/tabs/users/${user.id}`, 'back', 'replace'); + } + }, + onError(error) { + setProgress(false); + setError(error.message); + setSubmitting(false); + }, + }, + ); + }} + testid={`${testid}-form`} + /> + + + +
+ ); +}; + +export default UserEdit; diff --git a/src/pages/Users/components/UserEdit/UserEditForm.scss b/src/pages/Users/components/UserEdit/UserEditForm.scss deleted file mode 100644 index 2339a4a..0000000 --- a/src/pages/Users/components/UserEdit/UserEditForm.scss +++ /dev/null @@ -1,32 +0,0 @@ -.form-user-edit { - max-width: 45rem; - - margin: 1rem 0; - - ion-input { - margin-bottom: 0.5rem; - } - - .buttons { - display: flex; - - ion-button { - width: 100%; - } - - .spinner-button-save { - ion-spinner { - height: 16px; - width: 16px; - } - } - } - - .row-message { - margin: 1rem 0; - - &.row-card .wrapper { - width: 100% !important; - } - } -} diff --git a/src/pages/Users/components/UserEdit/UserEditForm.tsx b/src/pages/Users/components/UserEdit/UserEditForm.tsx deleted file mode 100644 index 8fa1022..0000000 --- a/src/pages/Users/components/UserEdit/UserEditForm.tsx +++ /dev/null @@ -1,184 +0,0 @@ -import { useState } from 'react'; -import { IonButton, useIonRouter } from '@ionic/react'; -import { Form, Formik } from 'formik'; -import { object, string } from 'yup'; -import classNames from 'classnames'; - -import './UserEditForm.scss'; -import { BaseComponentProps } from 'common/components/types'; -import { User } from 'common/models/user'; -import { useUpdateUser } from 'pages/Users/api/useUpdateUser'; -import { useToasts } from 'common/hooks/useToasts'; -import { DismissButton } from 'common/components/Toast/Toast'; -import Input from 'common/components/Input/Input'; -import CardRow from 'common/components/Card/CardRow'; -import ErrorCard from 'common/components/Card/ErrorCard'; -import LoaderSpinner from 'common/components/Loader/LoaderSpinner'; -import Icon, { IconName } from 'common/components/Icon/Icon'; -import HeaderRow from 'common/components/Text/HeaderRow'; - -/** - * Properties for the `UserEditForm` component. - * @see {@link BaseComponentProps} - */ -interface UserEditFormProps extends BaseComponentProps { - user: User; -} - -/** - * User edit form values. - * @param {User} user - A `User` object. - */ -interface UserEditFormValues { - user: User; -} - -/** - * User edit form validation schema. - */ -const validationSchema = object({ - user: object({ - name: string().required('Required. '), - username: string() - .required('Required. ') - .min(8, 'Must be at least 8 characters. ') - .max(30, 'Must be at most 30 characters. '), - email: string().required('Required. ').email('Must be an email address. '), - phone: string().required('Required. '), - website: string().url('Must be a URL. '), - }), -}); - -/** - * The `UserEditForm` component renders a Formik form for editing a `User`. - * @param {UserEditFormProps} props - Component properties. - * @returns {JSX.Element} JSX - */ -const UserEditForm = ({ - className, - user, - testid = 'form-user-edit', -}: UserEditFormProps): JSX.Element => { - const router = useIonRouter(); - const [error, setError] = useState(''); - const { mutate: updateUser, isPending } = useUpdateUser(); - const { createToast } = useToasts(); - - const onCancel = () => { - router.goBack(); - }; - - return ( -
- {error && ( - - - - )} - - enableReinitialize={true} - initialValues={{ user: user }} - onSubmit={(values, { setSubmitting }) => { - setError(''); - updateUser( - { user: { ...user, ...values.user } }, - { - onSuccess: (user) => { - setSubmitting(false); - createToast({ - buttons: [DismissButton], - duration: 5000, - message: `${user.name} updated`, - }); - if (router.canGoBack()) { - router.goBack(); - } else { - router.push(`/tabs/users/${user.id}`, 'back', 'replace'); - } - }, - onError(error) { - setError(error.message); - setSubmitting(false); - }, - }, - ); - }} - validationSchema={validationSchema} - > - {({ dirty, isSubmitting }) => ( -
- - -
Contact Info
-
- - - - - - -
- - Cancel - - - {isPending ? : 'Save'} - -
-
- )} - -
- ); -}; - -export default UserEditForm; diff --git a/src/pages/Users/components/UserEdit/UserEditPage.tsx b/src/pages/Users/components/UserEdit/UserEditPage.tsx index 31930a4..a6176b6 100644 --- a/src/pages/Users/components/UserEdit/UserEditPage.tsx +++ b/src/pages/Users/components/UserEdit/UserEditPage.tsx @@ -8,7 +8,8 @@ import Container from 'common/components/Content/Container'; import PageHeader from 'common/components/Content/PageHeader'; import Avatar from 'common/components/Icon/Avatar'; import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; -import UserEditForm from './UserEditForm'; +import UserEdit from './UserEdit'; +import ProgressProvider from 'common/providers/ProgressProvider'; /** * Router path parameters for the `UserEditPage`. @@ -30,29 +31,31 @@ export const UserEditPage = (): JSX.Element => { return ( -
+ +
- - - {user ? ( - <> - - - {user.name} - + + + {user ? ( + <> + + + {user.name} + - - - ) : ( - - )} - - + + + ) : ( + + )} + + + ); }; diff --git a/src/pages/Users/components/UserEdit/__tests__/UserEdit.test.tsx b/src/pages/Users/components/UserEdit/__tests__/UserEdit.test.tsx new file mode 100644 index 0000000..93846e5 --- /dev/null +++ b/src/pages/Users/components/UserEdit/__tests__/UserEdit.test.tsx @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { userFixture1 } from '__fixtures__/users'; + +import UserEdit from '../UserEdit'; + +describe('UserEdit', () => { + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-edit'); + + // ASSERT + expect(screen.getByTestId('user-edit')).toBeDefined(); + }); + + it('should submit form', async () => { + // ARRANGE + render(); + await screen.findByTestId('user-edit-form'); + + // ACT + await userEvent.click(screen.getByTestId('user-edit-form-field-name')); + await userEvent.clear(screen.getByLabelText('Name')); + await userEvent.type(screen.getByLabelText('Name'), 'test name'); + await userEvent.click(screen.getByTestId('user-edit-form-button-submit')); + + // ASSERT + expect(screen.getByTestId('user-edit-form')).toBeDefined(); + }); +}); diff --git a/src/pages/Users/components/UserEdit/__tests__/UserEditForm.test.tsx b/src/pages/Users/components/UserEdit/__tests__/UserEditForm.test.tsx deleted file mode 100644 index a806dfb..0000000 --- a/src/pages/Users/components/UserEdit/__tests__/UserEditForm.test.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import userEvent from '@testing-library/user-event'; - -import { render, screen } from 'test/test-utils'; -import { userFixture1 } from '__fixtures__/users'; - -import UserEditForm from '../UserEditForm'; - -describe('UserEditForm', () => { - it('should render successfully', async () => { - // ARRANGE - render(); - await screen.findByTestId('form-user-edit'); - - // ASSERT - expect(screen.getByTestId('form-user-edit')).toBeDefined(); - }); - - it('should submit form', async () => { - // ARRANGE - render(); - await screen.findByTestId('form'); - - // ACT - await userEvent.click(screen.getByTestId('form-field-name')); - await userEvent.clear(screen.getByLabelText('Name')); - await userEvent.type(screen.getByLabelText('Name'), 'test name'); - await userEvent.click(screen.getByTestId('form-button-submit')); - - // ASSERT - expect(screen.getByTestId('form')).toBeDefined(); - }); -}); diff --git a/src/pages/Users/components/UserForm/UserForm.scss b/src/pages/Users/components/UserForm/UserForm.scss new file mode 100644 index 0000000..dacb2df --- /dev/null +++ b/src/pages/Users/components/UserForm/UserForm.scss @@ -0,0 +1,13 @@ +.form-user { + ion-input { + margin-bottom: 0.5rem; + } + + .buttons { + display: flex; + + ion-button { + width: 100%; + } + } +} diff --git a/src/pages/Users/components/UserForm/UserForm.tsx b/src/pages/Users/components/UserForm/UserForm.tsx new file mode 100644 index 0000000..55e2e1e --- /dev/null +++ b/src/pages/Users/components/UserForm/UserForm.tsx @@ -0,0 +1,141 @@ +import { IonButton } from '@ionic/react'; +import { Form, Formik, FormikHelpers } from 'formik'; +import { object, string } from 'yup'; +import classNames from 'classnames'; + +import './UserForm.scss'; +import { BaseComponentProps } from 'common/components/types'; +import { User } from 'common/models/user'; +import Input from 'common/components/Input/Input'; + +/** + * User form values. + * @see {@link User} + */ +type UserFormValues = Pick; + +/** + * Properties for the `UserForm` component. + * @param {User} [user] - Optional. User to initialize the form. + * @see {@link BaseComponentProps} + */ +interface UserFormProps extends BaseComponentProps { + onCancel: () => void; + onSubmit: (values: UserFormValues, helpers: FormikHelpers) => void; + user?: User; +} + +/** + * User form validation schema. + */ +const validationSchema = object({ + name: string().required('Required. '), + username: string() + .required('Required. ') + .min(8, 'Must be at least 8 characters. ') + .max(30, 'Must be at most 30 characters. '), + email: string().required('Required. ').email('Must be an email address. '), + phone: string().required('Required. '), + website: string().url('Must be a URL. '), +}); + +/** + * The `UserForm` component renders a Formik form for creating or editing + * a `User`. + * @param {UserFormProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserForm = ({ + className, + onCancel, + onSubmit, + user, + testid = 'form-user', +}: UserFormProps): JSX.Element => { + return ( +
+ + enableReinitialize={true} + initialValues={{ + email: user?.email ?? '', + name: user?.name ?? '', + phone: user?.phone ?? '', + username: user?.username ?? '', + website: user?.website ?? '', + }} + onSubmit={onSubmit} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting }) => ( +
+ + + + + + +
+ + Cancel + + + Save + +
+
+ )} + +
+ ); +}; + +export default UserForm; diff --git a/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx b/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx new file mode 100644 index 0000000..367b8bd --- /dev/null +++ b/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx @@ -0,0 +1,41 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; + +import { render, screen } from 'test/test-utils'; +import { userFixture1 } from '__fixtures__/users'; + +import UserForm from '../UserForm'; + +describe('UserForm', () => { + it('should render successfully', async () => { + // ARRANGE + const mockOnCancel = vi.fn(); + const mockOnSubmit = vi.fn(); + render(); + await screen.findByTestId('form-user'); + + // ASSERT + expect(screen.getByTestId('form-user')).toBeDefined(); + }); + + it('should submit form', async () => { + // ARRANGE + const mockOnCancel = vi.fn(); + const mockOnSubmit = vi.fn(); + render(); + await screen.findByTestId('form-user'); + + // ACT + await userEvent.click(screen.getByTestId('form-user-field-name')); + await userEvent.type(screen.getByLabelText('Name'), 'name'); + await userEvent.type(screen.getByLabelText('Username'), 'username'); + await userEvent.type(screen.getByLabelText('Email'), 'test@example.com'); + await userEvent.type(screen.getByLabelText('Phone'), '123-456-7890'); + await userEvent.type(screen.getByLabelText('Website'), 'https://test.com'); + await userEvent.click(screen.getByTestId('form-user-button-submit')); + + // ASSERT + expect(screen.getByTestId('form-user')).toBeDefined(); + expect(mockOnSubmit).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/Users/components/UserList/UserListPage.scss b/src/pages/Users/components/UserList/UserListPage.scss index ca3c79c..c5f85b2 100644 --- a/src/pages/Users/components/UserList/UserListPage.scss +++ b/src/pages/Users/components/UserList/UserListPage.scss @@ -4,6 +4,7 @@ } .list-user { - margin: 1rem 0; + margin-top: 1rem; + margin-bottom: 5rem; } } diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx index 3c772d2..3ebec20 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -1,4 +1,6 @@ import { + IonButton, + IonButtons, IonContent, IonPage, IonRefresher, @@ -15,6 +17,8 @@ import PageHeader from 'common/components/Content/PageHeader'; import UserList from './UserList'; import UserGrid from './UserGrid'; import ProgressProvider from 'common/providers/ProgressProvider'; +import UserAddFab from '../UserAdd/UserAddFab'; +import Icon, { IconName } from 'common/components/Icon/Icon'; /** * The `UserListPage` component renders a list of all `User` objects. @@ -41,11 +45,22 @@ export const UserListPage = (): JSX.Element => { - Users +
Users
+ + + + +
+