Skip to content

Commit

Permalink
52 Toggle input (#65)
Browse files Browse the repository at this point in the history
* initial Toggle

* initial ToggleInput; SettingsForm

* docs

* ToggleInput

* tests

* tests

* tests

* remove delay util

* tests
  • Loading branch information
mwarman authored Aug 29, 2024
1 parent 3cb2ed3 commit c3632e8
Show file tree
Hide file tree
Showing 15 changed files with 629 additions and 43 deletions.
5 changes: 5 additions & 0 deletions src/__fixtures__/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Settings } from 'common/models/settings';

export const settingsFixture: Settings = {
allowNotifications: true,
};
49 changes: 49 additions & 0 deletions src/common/api/__tests__/useGetSettings.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
64 changes: 64 additions & 0 deletions src/common/api/__tests__/useUpdateSettings.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
36 changes: 36 additions & 0 deletions src/common/api/useGetSettings.ts
Original file line number Diff line number Diff line change
@@ -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<Settings>} A Promise which resolves to the `Settings` object.
*/
const getSettings = async (): Promise<Settings> => {
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(),
});
};
52 changes: 52 additions & 0 deletions src/common/api/useUpdateSettings.ts
Original file line number Diff line number Diff line change
@@ -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>} settings - The updated `Settings` attributes.
*/
export type UpdateSettingsVariables = {
settings: Partial<Settings>;
};

/**
* 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<Settings>} A Promise which resolves to the updated `Settings`.
*/
const updateSettings = async ({ settings }: UpdateSettingsVariables): Promise<Settings> => {
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<Settings>([QueryKey.Settings], data);
// you may [also|instead] choose to invalidate certain cached queries, triggering refetch
// queryClient.invalidateQueries({ queryKey: [QueryKey.Settings] });
},
});
};
51 changes: 51 additions & 0 deletions src/common/components/Input/ToggleInput.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof IonToggle> {
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<boolean>(name);

const onChange = async (e: ToggleCustomEvent<ToggleChangeEventDetail>) => {
await helpers.setValue(e.detail.checked);
onIonChange?.(e);
};

return (
<IonToggle
className={classNames('input-toggle', className)}
checked={field.value}
onIonChange={onChange}
data-testid={testid}
{...toggleProps}
/>
);
};

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

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

it('should change value', async () => {
// ARRANGE
let value = false;
render(
<Formik<{ testField: boolean }>
initialValues={{ testField: value }}
onSubmit={(values) => {
value = values.testField;
}}
>
{(formikProps) => (
<Form>
<ToggleInput name="testField" onIonChange={() => formikProps.submitForm()} />
</Form>
)}
</Formik>,
);
await screen.findByTestId('input-toggle');

// ACT
await userEvent.click(screen.getByTestId('input-toggle'));

// ASSERT
expect(screen.getByTestId('input-toggle')).toBeDefined();
expect(value).toBe(true);
});
});
18 changes: 18 additions & 0 deletions src/common/hooks/__tests__/usePlatform.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
35 changes: 35 additions & 0 deletions src/common/hooks/usePlatform.ts
Original file line number Diff line number Diff line change
@@ -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,
};
};
6 changes: 6 additions & 0 deletions src/common/models/settings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/**
* The [user] `Settings` type.
*/
export type Settings = {
allowNotifications: boolean;
};
Loading

0 comments on commit c3632e8

Please sign in to comment.