diff --git a/src/__fixtures__/settings.ts b/src/__fixtures__/settings.ts new file mode 100644 index 0000000..78054bc --- /dev/null +++ b/src/__fixtures__/settings.ts @@ -0,0 +1,5 @@ +import { Settings } from 'common/models/settings'; + +export const settingsFixture: Settings = { + allowNotifications: true, +}; diff --git a/src/common/api/__tests__/useGetSettings.test.ts b/src/common/api/__tests__/useGetSettings.test.ts new file mode 100644 index 0000000..a5cc174 --- /dev/null +++ b/src/common/api/__tests__/useGetSettings.test.ts @@ -0,0 +1,49 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; +import { settingsFixture } from '__fixtures__/settings'; +import storage from 'common/utils/storage'; +import { DEFAULT_SETTINGS } from 'common/utils/constants'; + +import { useGetSettings } from '../useGetSettings'; + +describe('useGetUser', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should get settings', async () => { + // ARRANGE + const { result } = renderHook(() => useGetSettings()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toEqual(DEFAULT_SETTINGS); + }); + + it('should get settings from storage', async () => { + // ARRANGE + const getItemSpy = vi.spyOn(storage, 'getItem'); + getItemSpy.mockReturnValue(JSON.stringify(settingsFixture)); + const { result } = renderHook(() => useGetSettings()); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(result.current.isSuccess).toBe(true); + expect(result.current.data).toEqual({ ...DEFAULT_SETTINGS, ...settingsFixture }); + }); + + it('should return error', async () => { + // ARRANGE + const getItemSpy = vi.spyOn(storage, 'getItem'); + getItemSpy.mockImplementation(() => { + throw new Error('test'); + }); + const { result } = renderHook(() => useGetSettings()); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(result.current.isError).toBe(true); + }); +}); diff --git a/src/common/api/__tests__/useUpdateSettings.test.ts b/src/common/api/__tests__/useUpdateSettings.test.ts new file mode 100644 index 0000000..a3996fa --- /dev/null +++ b/src/common/api/__tests__/useUpdateSettings.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; +import { settingsFixture } from '__fixtures__/settings'; +import storage from 'common/utils/storage'; + +import { useUpdateSettings } from '../useUpdateSettings'; + +describe('useUpdateSettings', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should update settings', async () => { + // ARRANGE + let isSuccess = false; + const { result } = renderHook(() => useUpdateSettings()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { settings: settingsFixture }, + { + onSuccess: () => { + isSuccess = true; + }, + }, + ); + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + + // ASSERT + expect(isSuccess).toBe(true); + }); + + it('should error when update fails', async () => { + // ARRANGE + const setItemSpy = vi.spyOn(storage, 'setItem'); + setItemSpy.mockImplementation(() => { + throw new Error('test'); + }); + let isError = false; + let isSuccess = false; + const { result } = renderHook(() => useUpdateSettings()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ACT + result.current.mutate( + { settings: settingsFixture }, + { + onSuccess: () => { + isSuccess = true; + }, + onError: () => { + isError = true; + }, + }, + ); + await waitFor(() => expect(result.current.isError).toBe(true)); + + // ASSERT + expect(isError).toBe(true); + expect(isSuccess).toBe(false); + }); +}); diff --git a/src/common/api/useGetSettings.ts b/src/common/api/useGetSettings.ts new file mode 100644 index 0000000..16bc15f --- /dev/null +++ b/src/common/api/useGetSettings.ts @@ -0,0 +1,36 @@ +import { useQuery } from '@tanstack/react-query'; + +import { Settings } from 'common/models/settings'; +import { DEFAULT_SETTINGS, QueryKey, StorageKey } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +/** + * A query hook to fetch the user `Settings` values. + * @returns Returns a `UserQueryResult` with `Settings` data. + */ +export const useGetSettings = () => { + /** + * Fetches the [user] `Settings`. + * @returns {Promise} A Promise which resolves to the `Settings` object. + */ + const getSettings = async (): Promise => { + return new Promise((resolve, reject) => { + try { + const storedSettings = storage.getItem(StorageKey.Settings); + if (storedSettings) { + const settings = JSON.parse(storedSettings) as Settings; + return resolve({ ...DEFAULT_SETTINGS, ...settings }); + } else { + return resolve(DEFAULT_SETTINGS); + } + } catch (error) { + return reject(error); + } + }); + }; + + return useQuery({ + queryKey: [QueryKey.Settings], + queryFn: () => getSettings(), + }); +}; diff --git a/src/common/api/useUpdateSettings.ts b/src/common/api/useUpdateSettings.ts new file mode 100644 index 0000000..4ea25f3 --- /dev/null +++ b/src/common/api/useUpdateSettings.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; + +import { Settings } from 'common/models/settings'; +import { DEFAULT_SETTINGS, QueryKey, StorageKey } from 'common/utils/constants'; +import storage from 'common/utils/storage'; + +/** + * The `useUpdateSettings` mutation function variables. + * @param {Partial} settings - The updated `Settings` attributes. + */ +export type UpdateSettingsVariables = { + settings: Partial; +}; + +/** + * A mutation hook which updates the user `Settings`. Returns a `UseMutationResult` + * object whose `mutate` attribute is a function to update the `Settings`. + * + * When successful, the hook updates the cached `Settings` query data. + * @returns Returns a `UseMutationResult`. + */ +export const useUpdateSettings = () => { + const queryClient = useQueryClient(); + + /** + * Updates the [user] `Settings`. + * @param {UpdateSettingsVariables} variables - The mutation function variables. + * @returns {Promise} A Promise which resolves to the updated `Settings`. + */ + const updateSettings = async ({ settings }: UpdateSettingsVariables): Promise => { + return new Promise((resolve, reject) => { + try { + const storedSettings: Settings = JSON.parse(storage.getItem(StorageKey.Settings) ?? '{}'); + const updatedSettings: Settings = { ...DEFAULT_SETTINGS, ...storedSettings, ...settings }; + storage.setItem(StorageKey.Settings, JSON.stringify(updatedSettings)); + return resolve(updatedSettings); + } catch (error) { + return reject(error); + } + }); + }; + + return useMutation({ + mutationFn: updateSettings, + onSuccess: (data) => { + // update cached query data + queryClient.setQueryData([QueryKey.Settings], data); + // you may [also|instead] choose to invalidate certain cached queries, triggering refetch + // queryClient.invalidateQueries({ queryKey: [QueryKey.Settings] }); + }, + }); +}; diff --git a/src/common/components/Input/ToggleInput.tsx b/src/common/components/Input/ToggleInput.tsx new file mode 100644 index 0000000..de79c51 --- /dev/null +++ b/src/common/components/Input/ToggleInput.tsx @@ -0,0 +1,51 @@ +import { IonToggle, ToggleChangeEventDetail, ToggleCustomEvent } from '@ionic/react'; +import { ComponentPropsWithoutRef } from 'react'; +import { useField } from 'formik'; +import classNames from 'classnames'; + +import { PropsWithTestId } from '../types'; + +/** + * Properties for the `ToggleInput` component. + * @param {string} name - The field `name` attribute value. + * @see {@link PropsWithTestId} + * @see {@link IonToggle} + */ +interface ToggleInputProps extends PropsWithTestId, ComponentPropsWithoutRef { + name: string; +} + +/** + * The `ToggleInput` component renders a standardized `IonToggle` which is + * integrated with Formik. + * + * @param {ToggleInputProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const ToggleInput = ({ + className, + name, + onIonChange, + testid = 'input-toggle', + ...toggleProps +}: ToggleInputProps): JSX.Element => { + /* eslint-disable-next-line @typescript-eslint/no-unused-vars */ + const [field, meta, helpers] = useField(name); + + const onChange = async (e: ToggleCustomEvent) => { + await helpers.setValue(e.detail.checked); + onIonChange?.(e); + }; + + return ( + + ); +}; + +export default ToggleInput; diff --git a/src/common/components/Input/__tests__/ToggleInput.test.tsx b/src/common/components/Input/__tests__/ToggleInput.test.tsx new file mode 100644 index 0000000..2a7894e --- /dev/null +++ b/src/common/components/Input/__tests__/ToggleInput.test.tsx @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { Form, Formik } from 'formik'; + +import { render, screen } from 'test/test-utils'; + +import ToggleInput from '../ToggleInput'; + +describe('ToggleInput', () => { + it('should render successfully', async () => { + // ARRANGE + render( + {}}> +
+ + +
, + ); + await screen.findByTestId('input-toggle'); + + // ASSERT + expect(screen.getByTestId('input-toggle')).toBeDefined(); + }); + + it('should change value', async () => { + // ARRANGE + let value = false; + render( + + initialValues={{ testField: value }} + onSubmit={(values) => { + value = values.testField; + }} + > + {(formikProps) => ( +
+ formikProps.submitForm()} /> + + )} + , + ); + await screen.findByTestId('input-toggle'); + + // ACT + await userEvent.click(screen.getByTestId('input-toggle')); + + // ASSERT + expect(screen.getByTestId('input-toggle')).toBeDefined(); + expect(value).toBe(true); + }); +}); diff --git a/src/common/hooks/__tests__/usePlatform.test.ts b/src/common/hooks/__tests__/usePlatform.test.ts new file mode 100644 index 0000000..93210ec --- /dev/null +++ b/src/common/hooks/__tests__/usePlatform.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; + +import { renderHook, waitFor } from 'test/test-utils'; + +import { usePlatform } from '../usePlatform'; + +describe('usePlatform', () => { + it('should return platform details', async () => { + // ARRANGE + const { result } = renderHook(() => usePlatform()); + await waitFor(() => expect(result.current).not.toBeNull()); + + // ASSERT + expect(result.current).toBeDefined(); + expect(result.current.isNativePlatform).toBe(false); + expect(result.current.platforms.length).toBeGreaterThan(0); + }); +}); diff --git a/src/common/hooks/usePlatform.ts b/src/common/hooks/usePlatform.ts new file mode 100644 index 0000000..9535361 --- /dev/null +++ b/src/common/hooks/usePlatform.ts @@ -0,0 +1,35 @@ +import { Capacitor } from '@capacitor/core'; +import { getPlatforms } from '@ionic/react'; + +/** + * The `Platform` type has attributes which describe the platform on which the + * application is running. + * @param {boolean} isNativePlatform - Returns `true` if the application is + * running as a native mobile application; otherwise returns `false`. + * @param {string[]} platforms - An array of platforms describing the runtime + * environment. + * @see {@link https://ionicframework.com/docs/react/platform#platforms} + */ +type Platform = { + isNativePlatform: boolean; + platforms: string[]; +}; + +/** + * The `usePlatform` hook provides details about the `Platform` on which the + * application is running. + * + * @see {@link https://ionicframework.com/docs/react/platform} + * @returns {Platform} A `Platform` object. + */ +export const usePlatform = (): Platform => { + const isNativePlatform = Capacitor.isNativePlatform(); + console.log(`usePlatform::isNativePlatform::${isNativePlatform}`); + const platforms = getPlatforms(); + console.log(`usePlatform::platforms::${platforms}`); + + return { + isNativePlatform, + platforms, + }; +}; diff --git a/src/common/models/settings.ts b/src/common/models/settings.ts new file mode 100644 index 0000000..f8b9f42 --- /dev/null +++ b/src/common/models/settings.ts @@ -0,0 +1,6 @@ +/** + * The [user] `Settings` type. + */ +export type Settings = { + allowNotifications: boolean; +}; diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index b79b667..ed9b22d 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -1,7 +1,10 @@ +import { Settings } from 'common/models/settings'; + /** * React Query cache keys. */ export enum QueryKey { + Settings = 'Settings', Users = 'Users', UserTokens = 'UserTokens', } @@ -10,6 +13,14 @@ export enum QueryKey { * Local storage keys. */ export enum StorageKey { + Settings = 'ionic-playground.settings', User = 'ionic-playground.user', UserTokens = 'ionic-playground.user-tokens', } + +/** + * The default `Settings` values. + */ +export const DEFAULT_SETTINGS: Settings = { + allowNotifications: true, +}; diff --git a/src/pages/Account/AccountPage.scss b/src/pages/Account/AccountPage.scss index 5758bfc..07a978a 100644 --- a/src/pages/Account/AccountPage.scss +++ b/src/pages/Account/AccountPage.scss @@ -9,9 +9,5 @@ text-transform: uppercase; } } - - ion-item { - --background: transparent; - } } } diff --git a/src/pages/Account/AccountPage.tsx b/src/pages/Account/AccountPage.tsx index b18d1e7..315bb90 100644 --- a/src/pages/Account/AccountPage.tsx +++ b/src/pages/Account/AccountPage.tsx @@ -1,11 +1,22 @@ -import { IonContent, IonItem, IonLabel, IonList, IonListHeader, IonPage } from '@ionic/react'; +import { + IonCol, + IonContent, + IonGrid, + IonItem, + IonLabel, + IonList, + IonListHeader, + IonPage, + IonRow, +} from '@ionic/react'; import dayjs from 'dayjs'; import './AccountPage.scss'; import { PropsWithTestId } from 'common/components/types'; import { useConfig } from 'common/hooks/useConfig'; +import ProgressProvider from 'common/providers/ProgressProvider'; import Header from 'common/components/Header/Header'; -import Container from 'common/components/Content/Container'; +import SettingsForm from './components/Settings/SettingsForm'; /** * The `AccountPage` component renders a list of account related items which @@ -22,46 +33,58 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element return ( -
+ +
- - - - - Account - - - Profile - - - Sign Out - - + + + + + + + Account + + + Profile + + + Sign Out + + + - - - Settings - - - - Version {version} - - - + + + - - - Legal - - - Privacy policy - - - Terms and conditions - - - - + + + + Legal + + + Privacy policy + + + Terms and conditions + + + + + + + + About + + + Version {version} + + + + + + + ); }; diff --git a/src/pages/Account/components/Settings/SettingsForm.tsx b/src/pages/Account/components/Settings/SettingsForm.tsx new file mode 100644 index 0000000..0f48ce6 --- /dev/null +++ b/src/pages/Account/components/Settings/SettingsForm.tsx @@ -0,0 +1,119 @@ +import { IonItem, IonLabel, IonList, IonListHeader } from '@ionic/react'; +import classNames from 'classnames'; +import { Form, Formik } from 'formik'; +import { boolean, object } from 'yup'; + +import { useGetSettings } from 'common/api/useGetSettings'; +import { BaseComponentProps } from 'common/components/types'; +import { Settings } from 'common/models/settings'; +import { useUpdateSettings } from 'common/api/useUpdateSettings'; +import { useProgress } from 'common/hooks/useProgress'; +import { useToasts } from 'common/hooks/useToasts'; +import { DismissButton } from 'common/components/Toast/Toast'; +import ToggleInput from 'common/components/Input/ToggleInput'; +import LoaderSkeleton from 'common/components/Loader/LoaderSkeleton'; + +/** + * Settings form values. + * @see {@link Settings} + */ +type SettingsFormValues = Pick; + +/** + * Settings form validation schema. + */ +const validationSchema = object({ + allowNotifications: boolean(), +}); + +/** + * The `SettingsForm` component renders a Formik form to edit user settings. + * @param {BaseComponentProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const SettingsForm = ({ + className, + testid = 'form-settings', +}: BaseComponentProps): JSX.Element | false => { + const { data: settings, isLoading } = useGetSettings(); + const { mutate: updateSettings } = useUpdateSettings(); + const { setProgress } = useProgress(); + const { createToast } = useToasts(); + + if (isLoading) { + return ( +
+ + + Settings + + + + + +
+ ); + } + + if (settings) { + return ( + + enableReinitialize={true} + initialValues={{ + allowNotifications: settings.allowNotifications, + }} + onSubmit={(values, { setSubmitting }) => { + setProgress(true); + updateSettings( + { settings: values }, + { + onSuccess: () => { + createToast({ + message: 'Settings updated.', + duration: 3000, + buttons: [DismissButton], + }); + }, + onError: () => { + createToast({ + message: 'Unable to update settings.', + buttons: [DismissButton], + color: 'danger', + }); + }, + onSettled: () => { + setProgress(false); + setSubmitting(false); + }, + }, + ); + }} + validationSchema={validationSchema} + > + {({ isSubmitting, submitForm }) => ( +
+ + + Settings + + + submitForm()} + testid={`${testid}-field-allowNotifications`} + > + Notifications + + + +
+ )} + + ); + } else { + return false; + } +}; + +export default SettingsForm; diff --git a/src/pages/Account/components/Settings/tests/SettingsForm.test.tsx b/src/pages/Account/components/Settings/tests/SettingsForm.test.tsx new file mode 100644 index 0000000..07d5745 --- /dev/null +++ b/src/pages/Account/components/Settings/tests/SettingsForm.test.tsx @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { UseMutationResult, UseQueryResult } from '@tanstack/react-query'; + +import { render, screen } from 'test/test-utils'; +import { Settings } from 'common/models/settings'; +import { settingsFixture } from '__fixtures__/settings'; +import * as UseGetSettings from 'common/api/useGetSettings'; +import * as UseUpdateSettings from 'common/api/useUpdateSettings'; + +import SettingsForm from '../SettingsForm'; + +describe('SettingsForm', () => { + beforeEach(() => { + const useGetSettingsSpy = vi.spyOn(UseGetSettings, 'useGetSettings'); + useGetSettingsSpy.mockReturnValue({ + data: settingsFixture, + isLoading: false, + isSuccess: true, + isError: false, + } as unknown as UseQueryResult); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should render successfully', async () => { + // ARRANGE + render(); + await screen.findByTestId('form-settings'); + + // ASSERT + expect(screen.getByTestId('form-settings')).toBeDefined(); + }); + + it('should render loading state', async () => { + // ARRANGE + const useGetSettingsSpy = vi.spyOn(UseGetSettings, 'useGetSettings'); + useGetSettingsSpy.mockReturnValueOnce({ + data: undefined, + isLoading: true, + isSuccess: false, + isError: false, + } as unknown as UseQueryResult); + render(); + await screen.findByTestId('form-loading'); + + // ASSERT + expect(screen.getByTestId('form-loading')).toBeDefined(); + }); + + it('should submit form', async () => { + // ARRANGE + const mockUpdateSettings = vi.fn(); + const useUpdateSettingsSpy = vi.spyOn(UseUpdateSettings, 'useUpdateSettings'); + useUpdateSettingsSpy.mockReturnValueOnce({ + mutate: mockUpdateSettings, + } as unknown as UseMutationResult); + render(); + await screen.findByTestId('form'); + + // ACT + await userEvent.click(screen.getByTestId('form-field-allowNotifications')); + + // ASSERT + expect(screen.getByTestId('form')).toBeDefined(); + expect(mockUpdateSettings).toHaveBeenCalled(); + }); +});