Skip to content

Commit

Permalink
45 Checkbox (#78)
Browse files Browse the repository at this point in the history
* initial CheckboxInput

* SigninForm remember me

* tests

* tests

* tests

* tests

* tests
  • Loading branch information
mwarman authored Sep 20, 2024
1 parent b3401b2 commit 83da635
Show file tree
Hide file tree
Showing 10 changed files with 425 additions and 5 deletions.
3 changes: 3 additions & 0 deletions src/common/components/Input/CheckboxInput.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ion-checkbox.ls-checkbox-input {
width: 100%;
}
76 changes: 76 additions & 0 deletions src/common/components/Input/CheckboxInput.tsx
Original file line number Diff line number Diff line change
@@ -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<ComponentPropsWithoutRef<typeof IonCheckbox>, 'name'>,
Required<Pick<ComponentPropsWithoutRef<typeof IonCheckbox>, '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<void> => {
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 (
<IonCheckbox
className={classNames('ls-checkbox-input', className)}
data-testid={testid}
onIonChange={onChange}
{...field}
{...checkboxProps}
></IonCheckbox>
);
};

export default CheckboxInput;
144 changes: 144 additions & 0 deletions src/common/components/Input/__tests__/CheckboxInput.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<Formik initialValues={{ checkboxField: false }} onSubmit={() => {}}>
<Form>
<CheckboxInput name="checkboxField" testid="input">
MyCheckbox
</CheckboxInput>
</Form>
</Formik>,
);
await screen.findByTestId('input');

// ASSERT
expect(screen.getByTestId('input')).toBeDefined();
});

it('should not be checked', async () => {
// ARRANGE
render(
<Formik initialValues={{ checkboxField: false }} onSubmit={() => {}}>
<Form>
<CheckboxInput name="checkboxField" testid="input">
MyCheckbox
</CheckboxInput>
</Form>
</Formik>,
);
await screen.findByTestId('input');

// ASSERT
expect(screen.getByTestId('input')).toBeDefined();
expect(screen.getByTestId('input')).toHaveAttribute('checked', 'false');
});

it('should be checked', async () => {
// ARRANGE
render(
<Formik initialValues={{ checkboxField: true }} onSubmit={() => {}}>
<Form>
<CheckboxInput name="checkboxField" testid="input">
MyCheckbox
</CheckboxInput>
</Form>
</Formik>,
);
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(
<Formik initialValues={{ checkboxField: false }} onSubmit={() => {}}>
<Form>
<CheckboxInput name="checkboxField" testid="input">
MyCheckbox
</CheckboxInput>
</Form>
</Formik>,
);
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(
<Formik initialValues={{ checkboxField: [] }} onSubmit={() => {}}>
<Form>
<CheckboxInput name="checkboxField" value="One" testid="one">
CheckboxOne
</CheckboxInput>
<CheckboxInput name="checkboxField" value="Two" testid="two">
CheckboxTwo
</CheckboxInput>
</Form>
</Formik>,
);
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(
<Formik initialValues={{ checkboxField: false }} onSubmit={() => {}}>
<Form>
<CheckboxInput name="checkboxField" onIonChange={onChange} testid="input">
MyCheckbox
</CheckboxInput>
</Form>
</Formik>,
);
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');
});
});
7 changes: 7 additions & 0 deletions src/common/models/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
82 changes: 82 additions & 0 deletions src/common/utils/__tests__/storage.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
1 change: 1 addition & 0 deletions src/common/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
37 changes: 37 additions & 0 deletions src/common/utils/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = <T>(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.
*
Expand Down Expand Up @@ -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 = <T>(key: StorageKey, value: T): void => {
localStorage.setItem(key, JSON.stringify(value));
};

const storage = {
getItem,
getJsonItem,
removeItem,
setItem,
setJsonItem,
};

export default storage;
9 changes: 7 additions & 2 deletions src/pages/Auth/SignIn/components/SignInForm.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading

0 comments on commit 83da635

Please sign in to comment.