diff --git a/src/common/components/Icon/Icon.tsx b/src/common/components/Icon/Icon.tsx index b040483..123b35b 100644 --- a/src/common/components/Icon/Icon.tsx +++ b/src/common/components/Icon/Icon.tsx @@ -19,6 +19,7 @@ import { faUser, faUserGear, faUsers, + faXmark, } from '@fortawesome/free-solid-svg-icons'; import classNames from 'classnames'; @@ -56,6 +57,7 @@ export enum IconName { User = 'user', Users = 'users', UserGear = 'user_gear', + Xmark = 'xmark', } /** @@ -78,6 +80,7 @@ const icons: Record = { user_gear: faUserGear, user: faUser, users: faUsers, + xmark: faXmark, }; /** diff --git a/src/common/components/Router/TabNavigation.tsx b/src/common/components/Router/TabNavigation.tsx index 8f810d6..8843965 100644 --- a/src/common/components/Router/TabNavigation.tsx +++ b/src/common/components/Router/TabNavigation.tsx @@ -8,7 +8,6 @@ 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'; import ProfilePage from 'pages/Account/components/Profile/ProfilePage'; import DiagnosticsPage from 'pages/Account/components/Diagnostics/DiagnosticsPage'; @@ -48,9 +47,6 @@ const TabNavigation = (): JSX.Element => { - - - diff --git a/src/pages/Users/components/UserAdd/UserAdd.tsx b/src/pages/Users/components/UserAdd/UserAdd.tsx deleted file mode 100644 index 45a5a9c..0000000 --- a/src/pages/Users/components/UserAdd/UserAdd.tsx +++ /dev/null @@ -1,77 +0,0 @@ -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 index 187c6a0..d30d356 100644 --- a/src/pages/Users/components/UserAdd/UserAddFab.scss +++ b/src/pages/Users/components/UserAdd/UserAddFab.scss @@ -1,4 +1,4 @@ -.fab-user-add { +.ls-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 index 4900d40..d5fe77e 100644 --- a/src/pages/Users/components/UserAdd/UserAddFab.tsx +++ b/src/pages/Users/components/UserAdd/UserAddFab.tsx @@ -1,14 +1,17 @@ +import { ComponentPropsWithoutRef } from 'react'; import { IonFab, IonFabButton } from '@ionic/react'; import classNames from 'classnames'; import './UserAddFab.scss'; -import { BaseComponentProps } from 'common/components/types'; +import { PropsWithTestId } from 'common/components/types'; import Icon, { IconName } from 'common/components/Icon/Icon'; /** * Properties for the `UserAddFab` component. + * @see {@link PropsWithTestId} + * @see {@link IonFab} */ -interface UserAddFabProps extends BaseComponentProps {} +interface UserAddFabProps extends PropsWithTestId, ComponentPropsWithoutRef {} /** * The `UserAddFab` renders an Ionic Floating Action Button, or FAB. @@ -16,16 +19,24 @@ interface UserAddFabProps extends BaseComponentProps {} * @param {UserAddFabProps} props - Component properties. * @returns {JSX.Element} JSX */ -const UserAddFab = ({ className, testid = 'fab-user-add' }: UserAddFabProps): JSX.Element => { +const UserAddFab = ({ + className, + horizontal = 'end', + slot = 'fixed', + testid = 'fab-user-add', + vertical = 'bottom', + ...fabProps +}: UserAddFabProps): JSX.Element => { return ( - + diff --git a/src/pages/Users/components/UserAdd/UserAddModal.tsx b/src/pages/Users/components/UserAdd/UserAddModal.tsx new file mode 100644 index 0000000..42e6c1d --- /dev/null +++ b/src/pages/Users/components/UserAdd/UserAddModal.tsx @@ -0,0 +1,116 @@ +import { IonModalCustomEvent } from '@ionic/core'; +import { OverlayEventDetail } from '@ionic/react/dist/types/components/react-component-lib/interfaces'; +import { + IonButton, + IonButtons, + IonContent, + IonHeader, + IonModal, + IonProgressBar, + IonTitle, + IonToolbar, + useIonRouter, +} from '@ionic/react'; +import { ComponentPropsWithoutRef, useState } from 'react'; + +import { PropsWithTestId } 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 Icon, { IconName } from 'common/components/Icon/Icon'; +import UserForm from '../UserForm/UserForm'; +import ErrorCard from 'common/components/Card/ErrorCard'; + +/** + * Properties for the `UserAddModal` component. + * @param {function} setIsOpen - A setter function which accepts a `boolean` that + * indicates if the modal should be open (`true`) or closed (`false`). + * @see {@link PropsWithTestId} + * @see {@link IonModal} + */ +interface UserAddModalProps extends PropsWithTestId, ComponentPropsWithoutRef { + setIsOpen: (isOpen: boolean) => void; +} + +/** + * The `UserAddModal` component renders an `IonModal` containing a Formik + * form to create a new `User`. + * + * @param {UserAddModalProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const UserAddModal = ({ + onIonModalDidDismiss, + setIsOpen, + testid = 'modal-user-add', + ...modalProps +}: UserAddModalProps): JSX.Element => { + const [error, setError] = useState(''); + const router = useIonRouter(); + const { isActive: isActiveProgressBar, progressBar, setProgress } = useProgress(); + const { createToast } = useToasts(); + const { mutate: createUser } = useCreateUser(); + + const didDismiss = (e: IonModalCustomEvent) => { + onIonModalDidDismiss?.(e); + setIsOpen(false); + }; + + return ( + + + + Add User + + + setIsOpen(false)} data-testid={`${testid}-button-close`}> + + + + + {isActiveProgressBar && } + + + + {error && ( + + )} + { + setProgress(true); + setError(''); + createUser( + { user: values }, + { + onSuccess: (user) => { + setProgress(false); + setSubmitting(false); + createToast({ + buttons: [DismissButton], + duration: 5000, + message: `${user.name} created`, + }); + setIsOpen(false); + router.push(`/tabs/users/${user.id}`); + }, + onError(error) { + setProgress(false); + setError(error.message); + setSubmitting(false); + }, + }, + ); + }} + testid={`${testid}-form`} + /> + + + ); +}; + +export default UserAddModal; diff --git a/src/pages/Users/components/UserAdd/UserAddPage.tsx b/src/pages/Users/components/UserAdd/UserAddPage.tsx deleted file mode 100644 index ee45cc3..0000000 --- a/src/pages/Users/components/UserAdd/UserAddPage.tsx +++ /dev/null @@ -1,35 +0,0 @@ -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 deleted file mode 100644 index 05685d5..0000000 --- a/src/pages/Users/components/UserAdd/__tests__/UserAdd.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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__/UserAddModal.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAddModal.test.tsx new file mode 100644 index 0000000..020fa5e --- /dev/null +++ b/src/pages/Users/components/UserAdd/__tests__/UserAddModal.test.tsx @@ -0,0 +1,70 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { IonContent, IonPage } from '@ionic/react'; +import { UseMutationResult } from '@tanstack/react-query'; + +import { render, screen, waitFor } from 'test/test-utils'; +import { User } from 'common/models/user'; +import * as UseCreateUser from 'pages/Users/api/useCreateUser'; + +import UserAddModal from '../UserAddModal'; + +describe('UserAddModal', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should render successfully', async () => { + // ARRANGE + const mockSetIsOpen = vi.fn(); + render(); + await screen.findByTestId('modal'); + + // ASSERT + expect(screen.getByTestId('modal')).toBeDefined(); + }); + + it.skip('should submit form', async () => { + // ARRANGE + const mockSetIsOpen = vi.fn(); + const mockCreateUser = vi.fn(); + const useCreateUserSpy = vi.spyOn(UseCreateUser, 'useCreateUser'); + useCreateUserSpy.mockReturnValueOnce({ + mutate: mockCreateUser, + } as unknown as UseMutationResult); + render(); + await screen.findByTestId('modal-form'); + + // ACT + // await userEvent.click(screen.getByTestId('modal-form-field-name')); + await userEvent.type(screen.getByLabelText('Name'), 'test name'); + await userEvent.type(screen.getByLabelText('Username'), 'username455'); + await userEvent.type(screen.getByLabelText('Email'), 'test@example.com'); + await userEvent.type(screen.getByLabelText('Phone'), '1234567890'); + await userEvent.click(screen.getByTestId('modal-form-button-submit')); + + // ASSERT + expect(screen.getByTestId('modal')).toBeDefined(); + expect(mockCreateUser).toHaveBeenCalled(); + }); + + it.skip('should dismiss', async () => { + // ARRANGE + const mockSetIsOpen = vi.fn(); + render( + + + + + , + ); + await screen.findByTestId('modal-button-close'); + + // ACT + await userEvent.click(screen.getByTestId('modal-button-close')); + await waitFor(() => expect(mockSetIsOpen).toHaveBeenCalled()); + + // ASSERT + expect(mockSetIsOpen).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx b/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx deleted file mode 100644 index 331b560..0000000 --- a/src/pages/Users/components/UserAdd/__tests__/UserAddPage.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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 index 9869d2e..9af730b 100644 --- a/src/pages/Users/components/UserEdit/UserEdit.tsx +++ b/src/pages/Users/components/UserEdit/UserEdit.tsx @@ -31,10 +31,6 @@ const UserEdit = ({ className, user, testid = 'user-edit' }: UserEditProps): JSX const { createToast } = useToasts(); const { setProgress } = useProgress(); - const onCancel = () => { - router.goBack(); - }; - return (
@@ -50,7 +46,6 @@ const UserEdit = ({ className, user, testid = 'user-edit' }: UserEditProps): JSX { setProgress(true); setError(''); diff --git a/src/pages/Users/components/UserForm/UserForm.scss b/src/pages/Users/components/UserForm/UserForm.scss index dacb2df..255149a 100644 --- a/src/pages/Users/components/UserForm/UserForm.scss +++ b/src/pages/Users/components/UserForm/UserForm.scss @@ -2,12 +2,4 @@ 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 index 55e2e1e..3e79760 100644 --- a/src/pages/Users/components/UserForm/UserForm.tsx +++ b/src/pages/Users/components/UserForm/UserForm.tsx @@ -1,3 +1,4 @@ +import { useEffect, useRef } from 'react'; import { IonButton } from '@ionic/react'; import { Form, Formik, FormikHelpers } from 'formik'; import { object, string } from 'yup'; @@ -20,7 +21,6 @@ type UserFormValues = Pick void; onSubmit: (values: UserFormValues, helpers: FormikHelpers) => void; user?: User; } @@ -47,11 +47,16 @@ const validationSchema = object({ */ const UserForm = ({ className, - onCancel, onSubmit, user, testid = 'form-user', }: UserFormProps): JSX.Element => { + const focusInput = useRef(null); + + useEffect(() => { + focusInput.current?.setFocus(); + }, []); + return (
@@ -74,7 +79,7 @@ const UserForm = ({ labelPlacement="stacked" disabled={isSubmitting} required - autoFocus + ref={focusInput} data-testid={`${testid}-field-name`} > -
- - Cancel - - - Save - -
+ + Save + )} diff --git a/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx b/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx index 367b8bd..7cadd5d 100644 --- a/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx +++ b/src/pages/Users/components/UserForm/__tests__/UserForm.test.tsx @@ -9,9 +9,8 @@ import UserForm from '../UserForm'; describe('UserForm', () => { it('should render successfully', async () => { // ARRANGE - const mockOnCancel = vi.fn(); const mockOnSubmit = vi.fn(); - render(); + render(); await screen.findByTestId('form-user'); // ASSERT @@ -20,9 +19,8 @@ describe('UserForm', () => { it('should submit form', async () => { // ARRANGE - const mockOnCancel = vi.fn(); const mockOnSubmit = vi.fn(); - render(); + render(); await screen.findByTestId('form-user'); // ACT diff --git a/src/pages/Users/components/UserList/UserListPage.tsx b/src/pages/Users/components/UserList/UserListPage.tsx index 3ebec20..598299d 100644 --- a/src/pages/Users/components/UserList/UserListPage.tsx +++ b/src/pages/Users/components/UserList/UserListPage.tsx @@ -7,9 +7,11 @@ import { IonRefresherContent, RefresherEventDetail, } from '@ionic/react'; +import { useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import './UserListPage.scss'; +import { PropsWithTestId } from 'common/components/types'; import { QueryKey } from 'common/utils/constants'; import Header from 'common/components/Header/Header'; import Container from 'common/components/Content/Container'; @@ -19,13 +21,14 @@ import UserGrid from './UserGrid'; import ProgressProvider from 'common/providers/ProgressProvider'; import UserAddFab from '../UserAdd/UserAddFab'; import Icon, { IconName } from 'common/components/Icon/Icon'; +import UserAddModal from '../UserAdd/UserAddModal'; /** * The `UserListPage` component renders a list of all `User` objects. * @returns JSX */ -export const UserListPage = (): JSX.Element => { - const testid = 'page-user-list'; +export const UserListPage = ({ testid = 'page-user-list' }: PropsWithTestId): JSX.Element => { + const [isOpenModal, setIsOpenModal] = useState(false); const queryClient = useQueryClient(); const handleRefresh = async (event: CustomEvent) => { @@ -49,8 +52,8 @@ export const UserListPage = (): JSX.Element => { setIsOpenModal(true)} data-testid={`${testid}-page-header-button-create`} > @@ -60,7 +63,8 @@ export const UserListPage = (): JSX.Element => { - + setIsOpenModal(true)} /> +