From ccfaedd208e458d141bc763b81c1553f4db93aa1 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 06:27:30 -0400 Subject: [PATCH 1/9] ProfilePage and ProfileForm components --- .../components/Router/TabNavigation.tsx | 4 + src/pages/Account/AccountPage.tsx | 2 +- src/pages/Account/api/useUpdateProfile.ts | 43 ++++ .../components/Profile/ProfileForm.scss | 9 + .../components/Profile/ProfileForm.tsx | 184 ++++++++++++++++++ .../components/Profile/ProfilePage.scss | 10 + .../components/Profile/ProfilePage.tsx | 50 +++++ src/pages/Users/api/useCreateUser.ts | 2 +- 8 files changed, 302 insertions(+), 2 deletions(-) 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.scss create mode 100644 src/pages/Account/components/Profile/ProfilePage.tsx 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/useUpdateProfile.ts b/src/pages/Account/api/useUpdateProfile.ts new file mode 100644 index 0000000..ba0f2ac --- /dev/null +++ b/src/pages/Account/api/useUpdateProfile.ts @@ -0,0 +1,43 @@ +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'; + +export type Profile = Pick; + +export type UpdateProfileVariables = { + profile: Profile; +}; + +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..5cbf7f5 --- /dev/null +++ b/src/pages/Account/components/Profile/ProfileForm.scss @@ -0,0 +1,9 @@ +.form-profile { + .row-buttons { + &.row-buttons-block { + ion-button { + flex-grow: 1; + } + } + } +} diff --git a/src/pages/Account/components/Profile/ProfileForm.tsx b/src/pages/Account/components/Profile/ProfileForm.tsx new file mode 100644 index 0000000..562bc40 --- /dev/null +++ b/src/pages/Account/components/Profile/ProfileForm.tsx @@ -0,0 +1,184 @@ +import { IonButton, IonRow, useIonRouter } from '@ionic/react'; +import { 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 ErrorCard from 'common/components/Card/ErrorCard'; +import Input from 'common/components/Input/Input'; +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'; + +/** + * 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 [error, setError] = useState(''); + const { mutate: updateProfile } = useUpdateProfile(); + const router = useIonRouter(); + const { setProgress } = useProgress(); + const { createToast } = useToasts(); + + 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.scss b/src/pages/Account/components/Profile/ProfilePage.scss new file mode 100644 index 0000000..66567e5 --- /dev/null +++ b/src/pages/Account/components/Profile/ProfilePage.scss @@ -0,0 +1,10 @@ +.page-profile { + .avatar { + border-radius: 0.25rem; + + height: 2rem; + width: 2rem; + + font-size: 1.5rem; + } +} diff --git a/src/pages/Account/components/Profile/ProfilePage.tsx b/src/pages/Account/components/Profile/ProfilePage.tsx new file mode 100644 index 0000000..faea2c7 --- /dev/null +++ b/src/pages/Account/components/Profile/ProfilePage.tsx @@ -0,0 +1,50 @@ +import { IonCol, IonContent, IonGrid, IonPage, IonRow } from '@ionic/react'; + +import './ProfilePage.scss'; +import { PropsWithTestId } from 'common/components/types'; +import ProgressProvider from 'common/providers/ProgressProvider'; +import Container from 'common/components/Content/Container'; +import PageHeader from 'common/components/Content/PageHeader'; +import Header from 'common/components/Header/Header'; +import { useGetCurrentUser } from 'common/api/useGetCurrentUser'; +import Avatar from 'common/components/Icon/Avatar'; +import ProfileForm from './ProfileForm'; + +const ProfilePage = ({ testid = 'page-profile' }: PropsWithTestId): JSX.Element => { + const { data: user, isLoading } = useGetCurrentUser(); + + return ( + + +
+ + + + {isLoading &&
Loading State
} + + {user && ( + <> + + +
{user.name}
+
+ + + + + + + + + + )} + + {!user &&
Not found state
} +
+
+ + + ); +}; + +export default ProfilePage; 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] }); }, From adc33e3ea6fb07296a82b541062adb2b0e0d2953 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 09:18:54 -0400 Subject: [PATCH 2/9] input ref; profile form autofocus --- src/common/components/Input/Input.tsx | 50 +++++++++++-------- .../components/Profile/ProfileForm.scss | 4 ++ .../components/Profile/ProfileForm.tsx | 14 ++++-- .../components/Profile/ProfilePage.tsx | 2 +- 4 files changed, 45 insertions(+), 25 deletions(-) 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/pages/Account/components/Profile/ProfileForm.scss b/src/pages/Account/components/Profile/ProfileForm.scss index 5cbf7f5..4613da9 100644 --- a/src/pages/Account/components/Profile/ProfileForm.scss +++ b/src/pages/Account/components/Profile/ProfileForm.scss @@ -1,4 +1,8 @@ .form-profile { + ion-input { + margin-bottom: 0.5rem; + } + .row-buttons { &.row-buttons-block { ion-button { diff --git a/src/pages/Account/components/Profile/ProfileForm.tsx b/src/pages/Account/components/Profile/ProfileForm.tsx index 562bc40..6d33db5 100644 --- a/src/pages/Account/components/Profile/ProfileForm.tsx +++ b/src/pages/Account/components/Profile/ProfileForm.tsx @@ -1,5 +1,5 @@ -import { IonButton, IonRow, useIonRouter } from '@ionic/react'; -import { useState } from 'react'; +import { IonButton, IonRow, useIonRouter, useIonViewDidEnter } from '@ionic/react'; +import { useRef, useState } from 'react'; import { Form, Formik } from 'formik'; import { object, string } from 'yup'; import classNames from 'classnames'; @@ -7,12 +7,12 @@ import classNames from 'classnames'; import './ProfileForm.scss'; import { BaseComponentProps } from 'common/components/types'; import { User } from 'common/models/user'; -import ErrorCard from 'common/components/Card/ErrorCard'; -import Input from 'common/components/Input/Input'; 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'; /** * Profile form values. @@ -53,12 +53,17 @@ const ProfileForm = ({ 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(); }; @@ -116,6 +121,7 @@ const ProfileForm = ({ labelPlacement="stacked" disabled={isSubmitting} autocomplete="off" + ref={focusInput} data-testid={`${testid}-field-name`} /> -
{user.name}
+
Profile
From 640a1e07b76249abfe34f5fd8fb59af037faf5b9 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 10:02:54 -0400 Subject: [PATCH 3/9] profilepage error and loading states --- .../components/Profile/ProfilePage.scss | 8 ------ .../components/Profile/ProfilePage.tsx | 28 ++++++++++++++----- 2 files changed, 21 insertions(+), 15 deletions(-) diff --git a/src/pages/Account/components/Profile/ProfilePage.scss b/src/pages/Account/components/Profile/ProfilePage.scss index 66567e5..f2028d0 100644 --- a/src/pages/Account/components/Profile/ProfilePage.scss +++ b/src/pages/Account/components/Profile/ProfilePage.scss @@ -1,10 +1,2 @@ .page-profile { - .avatar { - border-radius: 0.25rem; - - height: 2rem; - width: 2rem; - - font-size: 1.5rem; - } } diff --git a/src/pages/Account/components/Profile/ProfilePage.tsx b/src/pages/Account/components/Profile/ProfilePage.tsx index 3ff3300..2a0a126 100644 --- a/src/pages/Account/components/Profile/ProfilePage.tsx +++ b/src/pages/Account/components/Profile/ProfilePage.tsx @@ -2,16 +2,18 @@ import { IonCol, IonContent, IonGrid, IonPage, IonRow } from '@ionic/react'; import './ProfilePage.scss'; 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 { useGetCurrentUser } from 'common/api/useGetCurrentUser'; -import Avatar from 'common/components/Icon/Avatar'; 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, isLoading } = useGetCurrentUser(); + const { data: user, isError, isLoading } = useGetCurrentUser(); return ( @@ -20,12 +22,26 @@ const ProfilePage = ({ testid = 'page-profile' }: PropsWithTestId): JSX.Element - {isLoading &&
Loading State
} + {isLoading && ( +
+ + +
+ )} + + {isError && ( + + + + )} {user && ( <> -
Profile
@@ -38,8 +54,6 @@ const ProfilePage = ({ testid = 'page-profile' }: PropsWithTestId): JSX.Element
)} - - {!user &&
Not found state
} From 614992ce52ecbe9d5c238f5181ccade18ccfb2ff Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 10:04:16 -0400 Subject: [PATCH 4/9] styles --- src/pages/Account/components/Profile/ProfilePage.scss | 2 -- src/pages/Account/components/Profile/ProfilePage.tsx | 1 - 2 files changed, 3 deletions(-) delete mode 100644 src/pages/Account/components/Profile/ProfilePage.scss diff --git a/src/pages/Account/components/Profile/ProfilePage.scss b/src/pages/Account/components/Profile/ProfilePage.scss deleted file mode 100644 index f2028d0..0000000 --- a/src/pages/Account/components/Profile/ProfilePage.scss +++ /dev/null @@ -1,2 +0,0 @@ -.page-profile { -} diff --git a/src/pages/Account/components/Profile/ProfilePage.tsx b/src/pages/Account/components/Profile/ProfilePage.tsx index 2a0a126..a85d266 100644 --- a/src/pages/Account/components/Profile/ProfilePage.tsx +++ b/src/pages/Account/components/Profile/ProfilePage.tsx @@ -1,6 +1,5 @@ import { IonCol, IonContent, IonGrid, IonPage, IonRow } from '@ionic/react'; -import './ProfilePage.scss'; import { PropsWithTestId } from 'common/components/types'; import { useGetCurrentUser } from 'common/api/useGetCurrentUser'; import ProgressProvider from 'common/providers/ProgressProvider'; From df37e5c1a85879c0fa259df1861c6c9618b4febb Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 13:25:29 -0400 Subject: [PATCH 5/9] tests --- .../Profile/__tests__/ProfilePage.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx 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..0351beb --- /dev/null +++ b/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx @@ -0,0 +1,54 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { render, screen } from 'test/test-utils'; +import * as UseGetCurrentUser from 'common/api/useGetCurrentUser'; + +import ProfilePage from '../ProfilePage'; +import { UseQueryResult } from '@tanstack/react-query'; +import { User } from 'common/models/user'; +import { userFixture1 } from '__fixtures__/users'; + +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(); + }); +}); From 12fdbe8403264fa372bc0a3f6229780af693e23e Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 14:52:49 -0400 Subject: [PATCH 6/9] tests --- .../Profile/__tests__/ProfileForm.test.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/pages/Account/components/Profile/__tests__/ProfileForm.test.tsx 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(); + }); +}); From faf27a55f9c049ce203da48b531ead263281b04a Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Fri, 23 Aug 2024 15:19:54 -0400 Subject: [PATCH 7/9] tests --- .../api/__tests__/useUpdateProfile.test.ts | 97 +++++++++++++++++++ src/pages/Account/api/useUpdateProfile.ts | 14 +++ src/pages/Users/api/useUpdateUser.ts | 2 +- 3 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 src/pages/Account/api/__tests__/useUpdateProfile.test.ts 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 index ba0f2ac..b187ae5 100644 --- a/src/pages/Account/api/useUpdateProfile.ts +++ b/src/pages/Account/api/useUpdateProfile.ts @@ -4,12 +4,26 @@ 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(); 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. * From 5d5256c80fa26ba2e590ee01a2dbcfb5adf3c969 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 24 Aug 2024 07:43:30 -0400 Subject: [PATCH 8/9] fixes --- .../components/Profile/__tests__/ProfilePage.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx b/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx index 0351beb..1e991cb 100644 --- a/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx +++ b/src/pages/Account/components/Profile/__tests__/ProfilePage.test.tsx @@ -1,12 +1,12 @@ 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'; -import { UseQueryResult } from '@tanstack/react-query'; -import { User } from 'common/models/user'; -import { userFixture1 } from '__fixtures__/users'; describe('ProfilePage', () => { const useGetCurrentUserSpy = vi.spyOn(UseGetCurrentUser, 'useGetCurrentUser'); From 528232500372b84a5499a17a5b16025c92f1a4f5 Mon Sep 17 00:00:00 2001 From: Matthew Warman Date: Sat, 24 Aug 2024 08:38:15 -0400 Subject: [PATCH 9/9] buttonrow component --- src/common/components/Button/ButtonRow.scss | 7 ++++ src/common/components/Button/ButtonRow.tsx | 40 +++++++++++++++++++ .../Button/__tests__/ButtonRow.test.tsx | 21 ++++++++++ .../components/Profile/ProfileForm.scss | 8 ---- .../components/Profile/ProfileForm.tsx | 7 ++-- 5 files changed, 72 insertions(+), 11 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 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/pages/Account/components/Profile/ProfileForm.scss b/src/pages/Account/components/Profile/ProfileForm.scss index 4613da9..728c291 100644 --- a/src/pages/Account/components/Profile/ProfileForm.scss +++ b/src/pages/Account/components/Profile/ProfileForm.scss @@ -2,12 +2,4 @@ ion-input { margin-bottom: 0.5rem; } - - .row-buttons { - &.row-buttons-block { - ion-button { - flex-grow: 1; - } - } - } } diff --git a/src/pages/Account/components/Profile/ProfileForm.tsx b/src/pages/Account/components/Profile/ProfileForm.tsx index 6d33db5..36592aa 100644 --- a/src/pages/Account/components/Profile/ProfileForm.tsx +++ b/src/pages/Account/components/Profile/ProfileForm.tsx @@ -1,4 +1,4 @@ -import { IonButton, IonRow, useIonRouter, useIonViewDidEnter } from '@ionic/react'; +import { IonButton, useIonRouter, useIonViewDidEnter } from '@ionic/react'; import { useRef, useState } from 'react'; import { Form, Formik } from 'formik'; import { object, string } from 'yup'; @@ -13,6 +13,7 @@ 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. @@ -160,7 +161,7 @@ const ProfileForm = ({ data-testid={`${testid}-field-website`} /> - + Save - + )}