From 83da635bf493bdacd9f9dfc0f536a25b2c1aac18 Mon Sep 17 00:00:00 2001 From: Matt Warman Date: Fri, 20 Sep 2024 07:23:16 -0400 Subject: [PATCH] 45 Checkbox (#78) * initial CheckboxInput * SigninForm remember me * tests * tests * tests * tests * tests --- .../components/Input/CheckboxInput.scss | 3 + src/common/components/Input/CheckboxInput.tsx | 76 +++++++++ .../Input/__tests__/CheckboxInput.test.tsx | 144 ++++++++++++++++++ src/common/models/auth.ts | 7 + src/common/utils/__tests__/storage.test.ts | 82 ++++++++++ src/common/utils/constants.ts | 1 + src/common/utils/storage.ts | 37 +++++ .../Auth/SignIn/components/SignInForm.scss | 9 +- .../Auth/SignIn/components/SignInForm.tsx | 28 +++- .../components/__tests__/SignInForm.test.tsx | 43 +++++- 10 files changed, 425 insertions(+), 5 deletions(-) create mode 100644 src/common/components/Input/CheckboxInput.scss create mode 100644 src/common/components/Input/CheckboxInput.tsx create mode 100644 src/common/components/Input/__tests__/CheckboxInput.test.tsx create mode 100644 src/common/utils/__tests__/storage.test.ts diff --git a/src/common/components/Input/CheckboxInput.scss b/src/common/components/Input/CheckboxInput.scss new file mode 100644 index 0000000..5bd06c4 --- /dev/null +++ b/src/common/components/Input/CheckboxInput.scss @@ -0,0 +1,3 @@ +ion-checkbox.ls-checkbox-input { + width: 100%; +} diff --git a/src/common/components/Input/CheckboxInput.tsx b/src/common/components/Input/CheckboxInput.tsx new file mode 100644 index 0000000..02ab90a --- /dev/null +++ b/src/common/components/Input/CheckboxInput.tsx @@ -0,0 +1,76 @@ +import { ComponentPropsWithoutRef } from 'react'; +import { CheckboxCustomEvent, IonCheckbox } from '@ionic/react'; +import { useField } from 'formik'; +import classNames from 'classnames'; + +import './CheckboxInput.scss'; +import { PropsWithTestId } from '../types'; + +/** + * Properties for the `CheckboxInput`component. + * @see {@link PropsWithTestId} + * @see {@link IonCheckbox} + */ +interface CheckboxInputProps + extends PropsWithTestId, + Omit, 'name'>, + Required, 'name'>> {} + +/** + * The `CheckboxInput` component renders a standardized `IonCheckbox` which is + * integrated with Formik. + * + * CheckboxInput supports two types of field values: `boolean` and `string[]`. + * + * To create a `boolean` field, use a single `CheckboxInput` within a form and + * do not use the `value` prop. + * + * To create a `string[]` field, use one to many `CheckboxInput` within a form + * with the same `name` and a unique `value` property.s + * + * @param {CheckboxInputProps} props - Component properties. + * @returns {JSX.Element} JSX + */ +const CheckboxInput = ({ + className, + name, + onIonChange, + testid = 'ls-input-checkbox', + value, + ...checkboxProps +}: CheckboxInputProps): JSX.Element => { + const [field, meta, helpers] = useField({ + name, + type: 'checkbox', + value, + }); + + /** + * Handles changes to the field value as a result of a user action. + * @param {CheckboxCustomEvent} e - The event. + */ + const onChange = async (e: CheckboxCustomEvent): Promise => { + if (typeof meta.value === 'boolean') { + await helpers.setValue(e.detail.checked); + } else if (Array.isArray(meta.value)) { + if (e.detail.checked) { + await helpers.setValue([...meta.value, e.detail.value]); + } else { + await helpers.setValue(meta.value.filter((val) => val !== e.detail.value)); + } + } + onIonChange?.(e); + }; + + return ( + + ); +}; + +export default CheckboxInput; diff --git a/src/common/components/Input/__tests__/CheckboxInput.test.tsx b/src/common/components/Input/__tests__/CheckboxInput.test.tsx new file mode 100644 index 0000000..5aaa708 --- /dev/null +++ b/src/common/components/Input/__tests__/CheckboxInput.test.tsx @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { Form, Formik } from 'formik'; + +import { render, screen } from 'test/test-utils'; + +import CheckboxInput from '../CheckboxInput'; + +describe('CheckboxInput', () => { + it('should render successfully', async () => { + // ARRANGE + render( + {}}> +
+ + MyCheckbox + +
+
, + ); + await screen.findByTestId('input'); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + }); + + it('should not be checked', async () => { + // ARRANGE + render( + {}}> +
+ + MyCheckbox + +
+
, + ); + await screen.findByTestId('input'); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + expect(screen.getByTestId('input')).toHaveAttribute('checked', 'false'); + }); + + it('should be checked', async () => { + // ARRANGE + render( + {}}> +
+ + MyCheckbox + +
+
, + ); + await screen.findByTestId('input'); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + expect(screen.getByTestId('input')).toHaveAttribute('checked', 'true'); + }); + + it('should change boolean value', async () => { + // ARRANGE + const user = userEvent.setup(); + render( + {}}> +
+ + MyCheckbox + +
+
, + ); + await screen.findByTestId('input'); + expect(screen.getByTestId('input')).toHaveAttribute('checked', 'false'); + + // ACT + await user.click(screen.getByText('MyCheckbox')); + + // ASSERT + expect(screen.getByTestId('input')).toBeDefined(); + expect(screen.getByTestId('input')).toHaveAttribute('checked', 'true'); + }); + + it('should change array value', async () => { + // ARRANGE + const user = userEvent.setup(); + render( + {}}> +
+ + CheckboxOne + + + CheckboxTwo + +
+
, + ); + await screen.findByTestId('one'); + expect(screen.getByTestId('one')).toHaveAttribute('checked', 'false'); + expect(screen.getByTestId('two')).toHaveAttribute('checked', 'false'); + + // ACT + await user.click(screen.getByText('CheckboxOne')); + + // ASSERT + expect(screen.getByTestId('one')).toBeDefined(); + expect(screen.getByTestId('one')).toHaveAttribute('checked', 'true'); + expect(screen.getByTestId('two')).toHaveAttribute('checked', 'false'); + + // ACT + await user.click(screen.getByText('CheckboxOne')); + + // ASSERT + expect(screen.getByTestId('one')).toHaveAttribute('checked', 'false'); + expect(screen.getByTestId('two')).toHaveAttribute('checked', 'false'); + }); + + it.skip('should call onChange function', async () => { + // ARRANGE + const user = userEvent.setup(); + const onChange = vi.fn(); + + render( + {}}> +
+ + MyCheckbox + +
+
, + ); + await screen.findByText(/MyCheckbox/i); + + // ACT + await user.click(screen.getByText(/MyCheckbox/i)); + + // ASSERT + expect(onChange).toHaveBeenCalledTimes(1); + expect(screen.getByTestId('input')).toHaveAttribute('checked', 'true'); + }); +}); diff --git a/src/common/models/auth.ts b/src/common/models/auth.ts index 9c4b7ff..7e69b99 100644 --- a/src/common/models/auth.ts +++ b/src/common/models/auth.ts @@ -9,3 +9,10 @@ export type UserTokens = { expires_in: number; expires_at: string; }; + +/** + * The `RememberMe` type. Saved sign in attributes. + */ +export type RememberMe = { + username: string; +}; diff --git a/src/common/utils/__tests__/storage.test.ts b/src/common/utils/__tests__/storage.test.ts new file mode 100644 index 0000000..d5fff43 --- /dev/null +++ b/src/common/utils/__tests__/storage.test.ts @@ -0,0 +1,82 @@ +import { afterEach, describe, expect } from 'vitest'; + +import { StorageKey } from '../constants'; + +import storage from '../storage'; + +describe('storage', () => { + afterEach(() => { + localStorage.clear(); + }); + + it('should get item', () => { + // ARRANGE + const value = '1024'; + localStorage.setItem(StorageKey.RememberMe, value); + + // ASSERT + expect(storage.getItem(StorageKey.RememberMe)).toBe(value); + }); + + it('should set item', () => { + // ARRANGE + const value = '2048'; + storage.setItem(StorageKey.RememberMe, value); + + // ASSERT + expect(localStorage.getItem(StorageKey.RememberMe)).toBe(value); + }); + + it('should remove item', () => { + // ARRANGE + const value = '3072'; + storage.setItem(StorageKey.RememberMe, value); + expect(localStorage.getItem(StorageKey.RememberMe)).toBe(value); + + // ACT + storage.removeItem(StorageKey.RememberMe); + + // ASSERT + expect(localStorage.getItem(StorageKey.RememberMe)).toBeNull(); + }); + + it('should get JSON', () => { + // ARRANGE + const value = { id: 10, value: 'hello' }; + localStorage.setItem(StorageKey.RememberMe, JSON.stringify(value)); + + // ASSERT + expect(storage.getJsonItem(StorageKey.RememberMe)).toEqual(value); + }); + + it('should use fallback when cannot find JSON item', () => { + // ARRANGE + const value = { id: 10, value: 'hello' }; + const fallback = { id: 20, value: 'goodbye' }; + + localStorage.setItem(StorageKey.RememberMe, JSON.stringify(value)); + + // ASSERT + expect(storage.getJsonItem(StorageKey.Settings, fallback)).not.toEqual(value); + expect(storage.getJsonItem(StorageKey.Settings, fallback)).toEqual(fallback); + }); + + it('should return null when cannot find JSON item and no fallback', () => { + // ARRANGE + const value = { id: 10, value: 'hello' }; + + localStorage.setItem(StorageKey.RememberMe, JSON.stringify(value)); + + // ASSERT + expect(storage.getJsonItem(StorageKey.Settings)).toBeNull(); + }); + + it('should set JSON', () => { + // ARRANGE + const value = { id: 30, value: 'hola' }; + storage.setJsonItem(StorageKey.RememberMe, value); + + // ASSERT + expect(JSON.parse(localStorage.getItem(StorageKey.RememberMe) || '{}')).toEqual(value); + }); +}); diff --git a/src/common/utils/constants.ts b/src/common/utils/constants.ts index 1904897..7a9c925 100644 --- a/src/common/utils/constants.ts +++ b/src/common/utils/constants.ts @@ -15,6 +15,7 @@ export enum QueryKey { * Local storage keys. */ export enum StorageKey { + RememberMe = 'ionic-playground.remember-me', Settings = 'ionic-playground.settings', UserProfile = 'ionic-playground.user-profile', User = 'ionic-playground.user', diff --git a/src/common/utils/storage.ts b/src/common/utils/storage.ts index 3ba2b7e..69489df 100644 --- a/src/common/utils/storage.ts +++ b/src/common/utils/storage.ts @@ -11,6 +11,23 @@ const getItem = (key: StorageKey): string | null => { return localStorage.getItem(key); }; +/** + * Returns the current value associated with the given `key` or `null` if + * the given key does not exist. + * @param {StorageKey} key - The storage `key`. + * @param {T} [fallback] - Optional. Item to return if the requested item does + * not exist. + * @returns {T | null} Returns the value if found, otherwise `null`. + */ +const getJsonItem = (key: StorageKey, fallback?: T): T | null => { + const item = localStorage.getItem(key); + if (item) { + return JSON.parse(item); + } else { + return fallback ? fallback : null; + } +}; + /** * Removes the key/value pair with the given `key`, if a key/value pair with the given key exists. * @@ -39,10 +56,30 @@ const setItem = (key: StorageKey, value: string): void => { localStorage.setItem(key, value); }; +/** + * Sets the value of the pair identified by `key` to `value`, creating a new + * key/value pair if none existed for key previously. Use this function to + * store JSON objects. + * + * Throws a "QuotaExceededError" DOMException exception if the new value couldn't be set. + * (Setting could fail if, e.g., the user has disabled storage for the site, or if the quota has been exceeded.) + * + * Dispatches a storage event on Window objects holding an equivalent Storage + * object. + * @param {StorageKey} key - The storage `key`. + * @param {string} value - The `value` to be stored. + * @see {@link localStorage.setItem} + */ +const setJsonItem = (key: StorageKey, value: T): void => { + localStorage.setItem(key, JSON.stringify(value)); +}; + const storage = { getItem, + getJsonItem, removeItem, setItem, + setJsonItem, }; export default storage; diff --git a/src/pages/Auth/SignIn/components/SignInForm.scss b/src/pages/Auth/SignIn/components/SignInForm.scss index c78d3fb..5138db1 100644 --- a/src/pages/Auth/SignIn/components/SignInForm.scss +++ b/src/pages/Auth/SignIn/components/SignInForm.scss @@ -3,8 +3,13 @@ margin-bottom: 0.25rem; } - ion-input { - margin-bottom: 0.5rem; + ion-input, + ion-checkbox { + margin-bottom: 1rem; + } + + ion-checkbox { + font-size: 0.75rem; } ion-button.button-submit { diff --git a/src/pages/Auth/SignIn/components/SignInForm.tsx b/src/pages/Auth/SignIn/components/SignInForm.tsx index e3a61f9..b39d2a6 100644 --- a/src/pages/Auth/SignIn/components/SignInForm.tsx +++ b/src/pages/Auth/SignIn/components/SignInForm.tsx @@ -9,16 +9,20 @@ import { import { useRef, useState } from 'react'; import classNames from 'classnames'; import { Form, Formik } from 'formik'; -import { object, string } from 'yup'; +import { boolean, object, string } from 'yup'; import './SignInForm.scss'; import { BaseComponentProps } from 'common/components/types'; +import { RememberMe } from 'common/models/auth'; +import storage from 'common/utils/storage'; +import { StorageKey } from 'common/utils/constants'; import { useSignIn } from '../api/useSignIn'; import { useProgress } from 'common/hooks/useProgress'; import Input from 'common/components/Input/Input'; import ErrorCard from 'common/components/Card/ErrorCard'; import Icon, { IconName } from 'common/components/Icon/Icon'; import HeaderRow from 'common/components/Text/HeaderRow'; +import CheckboxInput from 'common/components/Input/CheckboxInput'; /** * Properties for the `SignInForm` component. @@ -33,6 +37,7 @@ interface SignInFormProps extends BaseComponentProps {} interface SignInFormValues { username: string; password: string; + rememberMe: boolean; } /** @@ -41,6 +46,7 @@ interface SignInFormValues { const validationSchema = object({ username: string().required('Required. '), password: string().required('Required. '), + rememberMe: boolean().default(false), }); /** @@ -55,6 +61,9 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX const router = useIonRouter(); const { mutate: signIn } = useSignIn(); + // remember me details + const rememberMe = storage.getJsonItem(StorageKey.RememberMe); + useIonViewDidEnter(() => { focusInput.current?.setFocus(); }); @@ -71,12 +80,23 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX enableReinitialize={true} - initialValues={{ username: '', password: '' }} + initialValues={{ + username: rememberMe?.username ?? '', + password: '', + rememberMe: !!rememberMe, + }} onSubmit={(values, { setSubmitting }) => { setError(''); setShowProgress(true); signIn(values.username, { onSuccess: () => { + if (values.rememberMe) { + storage.setJsonItem(StorageKey.RememberMe, { + username: values.username, + }); + } else { + storage.removeItem(StorageKey.RememberMe); + } router.push('/tabs', 'forward', 'replace'); }, onError: (err: Error) => { @@ -118,6 +138,10 @@ const SignInForm = ({ className, testid = 'form-signin' }: SignInFormProps): JSX + + Remember me + + { + afterEach(() => { + vi.restoreAllMocks(); + }); + it('should render successfully', async () => { // ARRANGE render(); @@ -13,4 +21,37 @@ describe('SignInForm', () => { // ASSERT expect(screen.getByTestId('form-signin')).toBeDefined(); }); + + it('should submit the form', async () => { + // ARRANGE + const mockSignIn = vi.fn(); + const useSignInSpy = vi.spyOn(UseSignIn, 'useSignIn'); + useSignInSpy.mockReturnValueOnce({ + mutate: mockSignIn, + } as unknown as UseMutationResult); + render(); + await screen.findByLabelText('Username'); + + // ACT + await userEvent.type(screen.getByLabelText('Username'), 'Bret'); + await userEvent.type(screen.getByLabelText('Password'), 'a'); + await userEvent.click(screen.getByTestId('form-button-submit')); + + // ASSERT + expect(screen.getByTestId('form')).toBeDefined(); + expect(mockSignIn).toHaveBeenCalled(); + }); + + it('should display error', async () => { + render(); + await screen.findByLabelText('Username'); + + // ACT + await userEvent.type(screen.getByLabelText('Username'), 'Unknown'); + await userEvent.type(screen.getByLabelText('Password'), 'a'); + await userEvent.click(screen.getByTestId('form-button-submit')); + + // ASSERT + expect(screen.getByTestId('form-error')).toBeDefined(); + }); });