From 067c74f24b7834b9200a36314c0540c75b6e6654 Mon Sep 17 00:00:00 2001 From: Matt Warman Date: Sat, 24 Aug 2024 08:41:57 -0400 Subject: [PATCH] 44 Edit profile (#61) * ProfilePage and ProfileForm components * input ref; profile form autofocus * profilepage error and loading states * styles * tests * tests * tests * fixes * buttonrow component --- src/common/components/Button/ButtonRow.scss | 7 + src/common/components/Button/ButtonRow.tsx | 40 ++++ .../Button/__tests__/ButtonRow.test.tsx | 21 ++ src/common/components/Input/Input.tsx | 50 +++-- .../components/Router/TabNavigation.tsx | 4 + src/pages/Account/AccountPage.tsx | 2 +- .../api/__tests__/useUpdateProfile.test.ts | 97 +++++++++ src/pages/Account/api/useUpdateProfile.ts | 57 ++++++ .../components/Profile/ProfileForm.scss | 5 + .../components/Profile/ProfileForm.tsx | 191 ++++++++++++++++++ .../components/Profile/ProfilePage.tsx | 63 ++++++ .../Profile/__tests__/ProfileForm.test.tsx | 78 +++++++ .../Profile/__tests__/ProfilePage.test.tsx | 54 +++++ src/pages/Users/api/useCreateUser.ts | 2 +- src/pages/Users/api/useUpdateUser.ts | 2 +- 15 files changed, 650 insertions(+), 23 deletions(-) create mode 100644 src/common/components/Button/ButtonRow.scss create mode 100644 src/common/components/Button/ButtonRow.tsx create mode 100644 src/common/components/Button/__tests__/ButtonRow.test.tsx create mode 100644 src/pages/Account/api/__tests__/useUpdateProfile.test.ts create mode 100644 src/pages/Account/api/useUpdateProfile.ts create mode 100644 src/pages/Account/components/Profile/ProfileForm.scss create mode 100644 src/pages/Account/components/Profile/ProfileForm.tsx create mode 100644 src/pages/Account/components/Profile/ProfilePage.tsx create mode 100644 src/pages/Account/components/Profile/__tests__/ProfileForm.test.tsx create mode 100644 src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx diff --git a/src/common/components/Button/ButtonRow.scss b/src/common/components/Button/ButtonRow.scss new file mode 100644 index 0000000..94c83b5 --- /dev/null +++ b/src/common/components/Button/ButtonRow.scss @@ -0,0 +1,7 @@ +.button-row { + &.button-row-block { + ion-button { + flex-grow: 1; + } + } +} diff --git a/src/common/components/Button/ButtonRow.tsx b/src/common/components/Button/ButtonRow.tsx new file mode 100644 index 0000000..4b398f6 --- /dev/null +++ b/src/common/components/Button/ButtonRow.tsx @@ -0,0 +1,40 @@ +import { IonRow } from '@ionic/react'; +import { ComponentPropsWithoutRef } from 'react'; +import classNames from 'classnames'; + +import './ButtonRow.scss'; +import { BaseComponentProps } from '../types'; + +/** + * Properties for the `ButtonRow` component. + * @see {@link BaseComponentProps} + * @see {@link IonRow} + */ +interface ButtonRowProps extends BaseComponentProps, ComponentPropsWithoutRef { + expand?: 'block'; +} + +/** + * The `ButtonRow` component renders an `IonRow` for the display of one or more + * `IonButton` components in a horizontal row. + * + * Use the `expand` property control how buttons are displayed within the row. + * @param {ButtonRowProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const ButtonRow = ({ + className, + expand, + testid = 'row-button', + ...rowProps +}: ButtonRowProps): JSX.Element => { + return ( + + ); +}; + +export default ButtonRow; diff --git a/src/common/components/Button/__tests__/ButtonRow.test.tsx b/src/common/components/Button/__tests__/ButtonRow.test.tsx new file mode 100644 index 0000000..6c72fa9 --- /dev/null +++ b/src/common/components/Button/__tests__/ButtonRow.test.tsx @@ -0,0 +1,21 @@ +import { describe, expect } from 'vitest'; + +import { render, screen } from 'test/test-utils'; + +import ButtonRow from '../ButtonRow'; + +describe('ButtonRow', () => { + it('should render successfully', async () => { + // ARRANGE + render( + +
+
, + ); + await screen.findByTestId('row-button'); + + // ASSERT + expect(screen.getByTestId('row-button')).toBeDefined(); + expect(screen.getByTestId('children')).toBeDefined(); + }); +}); diff --git a/src/common/components/Input/Input.tsx b/src/common/components/Input/Input.tsx index 9c0add2..026dd02 100644 --- a/src/common/components/Input/Input.tsx +++ b/src/common/components/Input/Input.tsx @@ -3,6 +3,7 @@ import classNames from 'classnames'; import { BaseComponentProps } from '../types'; import { useField } from 'formik'; +import { forwardRef } from 'react'; /** * Properties for the `Input` component. @@ -17,29 +18,38 @@ interface InputProps /** * The `Input` component renders a standardized `IonInput` which is integrated * with Formik. + * + * Optionally accepts a forwarded `ref` which allows the parent to manipulate + * the input, performing actions programmatically such as giving focus. + * * @param {InputProps} props - Component properties. + * @param {ForwardedRef} [ref] - Optional. A forwarded `ref`. * @returns {JSX.Element} JSX */ -const Input = ({ className, testid = 'input', ...props }: InputProps): JSX.Element => { - const [field, meta, helpers] = useField(props.name); +const Input = forwardRef( + ({ className, testid = 'input', ...props }: InputProps, ref): JSX.Element => { + const [field, meta, helpers] = useField(props.name); - return ( - ) => - await helpers.setValue(e.detail.value) - } - data-testid={testid} - {...field} - {...props} - errorText={meta.error} - > - ); -}; + return ( + ) => + await helpers.setValue(e.detail.value) + } + data-testid={testid} + {...field} + {...props} + errorText={meta.error} + ref={ref} + > + ); + }, +); +Input.displayName = 'Input'; export default Input; diff --git a/src/common/components/Router/TabNavigation.tsx b/src/common/components/Router/TabNavigation.tsx index bfc3869..bcf3f91 100644 --- a/src/common/components/Router/TabNavigation.tsx +++ b/src/common/components/Router/TabNavigation.tsx @@ -10,6 +10,7 @@ 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'; /** * The `TabNavigation` component provides a router outlet for all of the @@ -52,6 +53,9 @@ const TabNavigation = (): JSX.Element => { + + + diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx index 348f56a..b18d1e7 100644 --- a/src/pages/Account/AccountPage.tsx +++ b/src/pages/Account/AccountPage.tsx @@ -30,7 +30,7 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element Account - + Profile diff --git a/src/pages/Account/api/__tests__/useUpdateProfile.test.ts b/src/pages/Account/api/__tests__/useUpdateProfile.test.ts new file mode 100644 index 0000000..db2544e --- /dev/null +++ b/src/pages/Account/api/__tests__/useUpdateProfile.test.ts @@ -0,0 +1,97 @@ +import { afterAll, describe, expect, it, vi } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; +import { userFixture1 } from '__fixtures__/users'; +import storage from 'common/utils/storage'; +import { StorageKey } from 'common/utils/constants'; + +import { useUpdateProfile } from '../useUpdateProfile'; + +describe('useUpdateProfile', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + afterAll(() => { + storage.removeItem(StorageKey.User); + }); + + it('should update profile', async () => { + // ARRANGE + let isSuccess = false; + storage.setItem(StorageKey.User, JSON.stringify(userFixture1)); + const { result } = renderHook(() => useUpdateProfile()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { profile: userFixture1 }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + }); + + it('should error when no stored profile', async () => { + // ARRANGE + let isSuccess = false; + let isError = false; + storage.removeItem(StorageKey.User); + const { result } = renderHook(() => useUpdateProfile()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { profile: userFixture1 }, + { + onSuccess: () => { + isSuccess = true; + }, + onError: () => { + isError = true; + }, + }, + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(false); + expect(isError).toBe(true); + }); + + it('should error when an error is caught in mutation function', async () => { + // ARRANGE + let isSuccess = false; + let isError = false; + const getItemSpy = vi.spyOn(storage, 'getItem'); + getItemSpy.mockImplementation(() => { + throw new Error('test'); + }); + const { result } = renderHook(() => useUpdateProfile()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { profile: userFixture1 }, + { + onSuccess: () => { + isSuccess = true; + }, + onError: () => { + isError = true; + }, + }, + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(false); + expect(isError).toBe(true); + }); +}); diff --git a/src/pages/Account/api/useUpdateProfile.ts b/src/pages/Account/api/useUpdateProfile.ts new file mode 100644 index 0000000..b187ae5 --- /dev/null +++ b/src/pages/Account/api/useUpdateProfile.ts @@ -0,0 +1,57 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { User } from 'common/models/user'; +import { QueryKey, StorageKey } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +/** + * The `Profile` object. This is a contrived type for demonstration purposes. + */ +export type Profile = Pick; + +/** + * The `useUpdateProfile` mutatation function variables. + * @param {Profile} profile - The updated `Profile` object. + */ +export type UpdateProfileVariables = { + profile: Profile; +}; + +/** + * An API hook which updates a single `Profile`. Returns a `UseMutationResult` + * object whose `mutate` attribute is a function to update a `Profile`. + * + * When successful, the hook updates the cached `Profile` query data. + * @returns Returns a `UseMutationResult`. + */ +export const useUpdateProfile = () => { + const queryClient = useQueryClient(); + + const updateProfile = ({ profile }: UpdateProfileVariables): Promise => { + return new Promise((resolve, reject) => { + try { + const storedProfile = storage.getItem(StorageKey.User); + if (storedProfile) { + const currentProfile: User = JSON.parse(storedProfile); + const updatedProfile: User = { ...currentProfile, ...profile }; + storage.setItem(StorageKey.User, JSON.stringify(updatedProfile)); + return resolve(updatedProfile); + } else { + return reject(new Error('Profile not found.')); + } + } catch (err) { + return reject(err); + } + }); + }; + + return useMutation({ + mutationFn: updateProfile, + onSuccess: (data) => { + // update cached query data + queryClient.setQueryData([QueryKey.Users, 'current'], data); + // you may [also|instead] choose to invalidate certain cached queries, triggering refetch + // queryClient.invalidateQueries({ queryKey: [QueryKey.Users, 'current'] }); + }, + }); +}; diff --git a/src/pages/Account/components/Profile/ProfileForm.scss b/src/pages/Account/components/Profile/ProfileForm.scss new file mode 100644 index 0000000..728c291 --- /dev/null +++ b/src/pages/Account/components/Profile/ProfileForm.scss @@ -0,0 +1,5 @@ +.form-profile { + ion-input { + margin-bottom: 0.5rem; + } +} diff --git a/src/pages/Account/components/Profile/ProfileForm.tsx b/src/pages/Account/components/Profile/ProfileForm.tsx new file mode 100644 index 0000000..36592aa --- /dev/null +++ b/src/pages/Account/components/Profile/ProfileForm.tsx @@ -0,0 +1,191 @@ +import { IonButton, useIonRouter, useIonViewDidEnter } from '@ionic/react'; +import { useRef, useState } from 'react'; +import { Form, Formik } from 'formik'; +import { object, string } from 'yup'; +import classNames from 'classnames'; + +import './ProfileForm.scss'; +import { BaseComponentProps } from 'common/components/types'; +import { User } from 'common/models/user'; +import { useProgress } from 'common/hooks/useProgress'; +import { useUpdateProfile } from 'pages/Account/api/useUpdateProfile'; +import { useToasts } from 'common/hooks/useToasts'; +import { DismissButton } from 'common/components/Toast/Toast'; +import ErrorCard from 'common/components/Card/ErrorCard'; +import Input from 'common/components/Input/Input'; +import ButtonRow from 'common/components/Button/ButtonRow'; + +/** + * Profile form values. + * @see {@link User} + */ +type ProfileFormValues = Pick; + +/** + * Properties for the `ProfileForm` component. + * @param {User} user - User to initialize the form. + * @see {@link BaseComponentProps} + */ +interface ProfileFormProps extends BaseComponentProps { + user: User; +} + +/** + * Profile 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 `ProfileForm` component renders a Formik form to edit a user profile. + * @param {ProfileFormProps} props - Component propeties. + * @returns {JSX.Element} JSX + */ +const ProfileForm = ({ + className, + testid = 'form-profile', + user, +}: ProfileFormProps): JSX.Element => { + const focusInput = useRef(null); + const [error, setError] = useState(''); + const { mutate: updateProfile } = useUpdateProfile(); + const router = useIonRouter(); + const { setProgress } = useProgress(); + const { createToast } = useToasts(); + + useIonViewDidEnter(() => { + focusInput.current?.setFocus(); + }); + + const onCancel = () => { + router.goBack(); + }; + + return ( +
+ {error && ( + + )} + + + enableReinitialize={true} + initialValues={{ + email: user.email, + name: user.name, + phone: user.phone, + username: user.username, + website: user.website, + }} + onSubmit={(values, { setSubmitting }) => { + setProgress(true); + setError(''); + updateProfile( + { profile: values }, + { + onSuccess: () => { + createToast({ + message: 'Updated profile', + duration: 5000, + buttons: [DismissButton], + }); + router.goBack(); + }, + onError: (err) => { + setError(err.message); + }, + onSettled: () => { + setProgress(false); + setSubmitting(false); + }, + }, + ); + }} + validationSchema={validationSchema} + > + {({ dirty, isSubmitting }) => ( +
+ + + + + + + + + Cancel + + + Save + + +
+ )} + +
+ ); +}; + +export default ProfileForm; diff --git a/src/pages/Account/components/Profile/ProfilePage.tsx b/src/pages/Account/components/Profile/ProfilePage.tsx new file mode 100644 index 0000000..a85d266 --- /dev/null +++ b/src/pages/Account/components/Profile/ProfilePage.tsx @@ -0,0 +1,63 @@ +import { IonCol, IonContent, IonGrid, IonPage, IonRow } from '@ionic/react'; + +import { PropsWithTestId } from 'common/components/types'; +import { useGetCurrentUser } from 'common/api/useGetCurrentUser'; +import ProgressProvider from 'common/providers/ProgressProvider'; +import Container from 'common/components/Content/Container'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; +import PageHeader from 'common/components/Content/PageHeader'; +import Header from 'common/components/Header/Header'; +import ProfileForm from './ProfileForm'; +import ErrorCard from 'common/components/Card/ErrorCard'; +import CardRow from 'common/components/Card/CardRow'; + +const ProfilePage = ({ testid = 'page-profile' }: PropsWithTestId): JSX.Element => { + const { data: user, isError, isLoading } = useGetCurrentUser(); + + return ( + + +
+ + + + {isLoading && ( +
+ + +
+ )} + + {isError && ( + + + + )} + + {user && ( + <> + +
Profile
+
+ + + + + + + + + + )} +
+
+ + + ); +}; + +export default ProfilePage; diff --git a/src/pages/Account/components/Profile/__tests__/ProfileForm.test.tsx b/src/pages/Account/components/Profile/__tests__/ProfileForm.test.tsx new file mode 100644 index 0000000..4497f98 --- /dev/null +++ b/src/pages/Account/components/Profile/__tests__/ProfileForm.test.tsx @@ -0,0 +1,78 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { UseMutationResult } from '@tanstack/react-query'; + +import { render, screen } from 'test/test-utils'; +import { User } from 'common/models/user'; +import * as UseUpdateProfile from 'pages/Account/api/useUpdateProfile'; +import { userFixture1 } from '__fixtures__/users'; + +import ProfileForm from '../ProfileForm'; + +const mockGoBack = vi.fn(); +vi.mock('@ionic/react', async () => { + const original = await vi.importActual('@ionic/react'); + return { + ...original, + useIonRouter: () => ({ + goBack: mockGoBack, + }), + }; +}); + +describe('ProfileForm', async () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('form-profile'); + + // ASSERT + expect(screen.getByTestId('form-profile')).toBeDefined(); + }); + + it('should submit form', async () => { + // ARRANGE + const mockUpdateProfile = vi.fn(); + const useUpdateProfileSpy = vi.spyOn(UseUpdateProfile, 'useUpdateProfile'); + useUpdateProfileSpy.mockReturnValueOnce({ + mutate: mockUpdateProfile, + } as unknown as UseMutationResult); + render(); + await screen.findByTestId('form-field-name'); + + // ACT + await userEvent.click(screen.getByTestId('form-field-name')); + await userEvent.clear(screen.getByLabelText('Name')); + await userEvent.type(screen.getByLabelText('Name'), 'Valid Name'); + await userEvent.clear(screen.getByLabelText('Username')); + await userEvent.type(screen.getByLabelText('Username'), 'ValidUsername'); + await userEvent.clear(screen.getByLabelText('Email')); + await userEvent.type(screen.getByLabelText('Email'), 'valid@email.com'); + await userEvent.clear(screen.getByLabelText('Phone')); + await userEvent.type(screen.getByLabelText('Phone'), '123-456-7890'); + await userEvent.clear(screen.getByLabelText('Website')); + await userEvent.type(screen.getByLabelText('Website'), 'http://valid.example.com'); + await userEvent.click(screen.getByTestId('form-button-submit')); + + // ASSERT + expect(screen.getByTestId('form')).toBeDefined(); + expect(mockUpdateProfile).toHaveBeenCalled(); + }); + + it('should perform cancel', async () => { + // ARRANGE + render(); + await screen.findByTestId('form'); + + // ACT + await userEvent.click(screen.getByTestId('form-button-cancel')); + + // ASSERT + expect(screen.getByTestId('form')).toBeDefined(); + expect(mockGoBack).toHaveBeenCalled(); + }); +}); diff --git a/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx b/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx new file mode 100644 index 0000000..1e991cb --- /dev/null +++ b/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; +import { UseQueryResult } from '@tanstack/react-query'; + +import { render, screen } from 'test/test-utils'; +import { User } from 'common/models/user'; +import { userFixture1 } from '__fixtures__/users'; +import * as UseGetCurrentUser from 'common/api/useGetCurrentUser'; + +import ProfilePage from '../ProfilePage'; + +describe('ProfilePage', () => { + const useGetCurrentUserSpy = vi.spyOn(UseGetCurrentUser, 'useGetCurrentUser'); + + it('should render successfully', async () => { + // ARRANGE + useGetCurrentUserSpy.mockReturnValueOnce({ + data: userFixture1, + isLoading: false, + isError: false, + isSuccess: true, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('page-profile'); + + // ASSERT + expect(screen.getByTestId('page-profile')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + useGetCurrentUserSpy.mockReturnValueOnce({ isLoading: true } as unknown as UseQueryResult< + User, + Error + >); + render(); + await screen.findByTestId('page-profile-loading'); + + // ASSERT + expect(screen.getByTestId('page-profile-loading')).toBeDefined(); + }); + + it('should render error state', async () => { + // ARRANGE + useGetCurrentUserSpy.mockReturnValueOnce({ + isError: true, + isLoading: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('page-profile-error'); + + // ASSERT + expect(screen.getByTestId('page-profile-error')).toBeDefined(); + }); +}); diff --git a/src/pages/Users/api/useCreateUser.ts b/src/pages/Users/api/useCreateUser.ts index f68b1f5..e04cb27 100644 --- a/src/pages/Users/api/useCreateUser.ts +++ b/src/pages/Users/api/useCreateUser.ts @@ -55,7 +55,7 @@ export const useCreateUser = () => { cachedUsers ? [...cachedUsers, data] : [data], ); queryClient.setQueryData([QueryKey.Users, userId], data); - // you may [also|instead] choose to invalidate certaincached queries, triggering refetch + // you may [also|instead] choose to invalidate certain cached 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 f77202c..418bd1b 100644 --- a/src/pages/Users/api/useUpdateUser.ts +++ b/src/pages/Users/api/useUpdateUser.ts @@ -16,7 +16,7 @@ export type UpdateUserVariables = { /** * An API hook which updates a single `User`. Returns a `UseMutationResult` - * object whose `mutate` attribute is a function to udate a `User`. + * object whose `mutate` attribute is a function to update a `User`. * * When successful, the hook updates the cached `User` query data. *