Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-title" content="Ionic Playground" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />

<meta name="mobile-web-app-capable" content="yes" />
</head>
<body>
<div id="root"></div>
Expand Down
270 changes: 108 additions & 162 deletions package-lock.json

Large diffs are not rendered by default.

24 changes: 12 additions & 12 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,11 @@
"@fortawesome/react-fontawesome": "0.2.2",
"@ionic/react": "8.2.7",
"@ionic/react-router": "8.2.7",
"@tanstack/react-query": "5.52.1",
"@tanstack/react-query-devtools": "5.52.1",
"@tanstack/react-query": "5.55.0",
"@tanstack/react-query-devtools": "5.55.0",
"@types/react-router": "5.1.20",
"@types/react-router-dom": "5.3.3",
"axios": "1.7.5",
"axios": "1.7.7",
"classnames": "2.5.1",
"dayjs": "1.11.13",
"formik": "2.4.6",
Expand All @@ -49,28 +49,28 @@
"@capacitor/cli": "6.1.2",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.5.0",
"@testing-library/react": "16.0.0",
"@testing-library/react": "16.0.1",
"@testing-library/user-event": "14.5.2",
"@types/lodash": "4.17.7",
"@types/react": "18.3.4",
"@types/react": "18.3.5",
"@types/react-dom": "18.3.0",
"@types/uuid": "10.0.0",
"@typescript-eslint/eslint-plugin": "8.2.0",
"@typescript-eslint/parser": "8.2.0",
"@typescript-eslint/eslint-plugin": "8.4.0",
"@typescript-eslint/parser": "8.4.0",
"@vitejs/plugin-legacy": "5.4.2",
"@vitejs/plugin-react": "4.3.1",
"@vitest/coverage-v8": "2.0.5",
"cypress": "13.13.3",
"cypress": "13.14.2",
"eslint": "8.57.0",
"eslint-plugin-react": "7.35.0",
"eslint-plugin-react": "7.35.2",
"eslint-plugin-react-hooks": "4.6.2",
"eslint-plugin-react-refresh": "0.4.11",
"jsdom": "25.0.0",
"msw": "2.3.5",
"sass": "1.77.8",
"msw": "2.4.2",
"sass": "1.78.0",
"terser": "5.31.6",
"typescript": "5.5.4",
"vite": "5.4.2",
"vite": "5.4.3",
"vitest": "2.0.5"
}
}
7 changes: 7 additions & 0 deletions src/__fixtures__/profiles.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { Profile } from 'common/models/profile';

export const profileFixture1: Profile = {
name: 'Test User',
email: 'test1@example.com',
bio: 'My name is Test User.',
};
66 changes: 66 additions & 0 deletions src/common/components/Input/Textarea.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { IonTextarea, TextareaCustomEvent } from '@ionic/react';
import { ComponentPropsWithoutRef, forwardRef } from 'react';
import { useField } from 'formik';
import classNames from 'classnames';

import { PropsWithTestId } from '../types';

/**
* Properties for the `Textarea` component.
* @see {@link PropsWithTestId}
* @see {@link IonTextarea}
*/
interface TextareaProps
extends PropsWithTestId,
Omit<ComponentPropsWithoutRef<typeof IonTextarea>, 'name'>,
Required<Pick<ComponentPropsWithoutRef<typeof IonTextarea>, 'name'>> {}

/**
* The `Textarea` component renders a standardized `IonTextarea` which is
* integrated with Formik.
*
* Optionally accepts a forwarded `ref` which allows the parent to manipulate
* the textarea, performing actions programmatically such as giving focus.
*
* @param {TextareaProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const Textarea = forwardRef<HTMLIonTextareaElement, TextareaProps>(
(
{ className, onIonInput, testid = 'textarea', ...textareaProps }: TextareaProps,
ref,
): JSX.Element => {
const [field, meta, helpers] = useField(textareaProps.name);

/**
* Handle changes to the textarea's value. Updates the Formik field state.
* Calls the supplied `onIonInput` props function if one was provided.
* @param {TextareaCustomEvent} e - The event.
*/
const onInput = async (e: TextareaCustomEvent) => {
await helpers.setValue(e.detail.value);
onIonInput?.(e);
};

return (
<IonTextarea
className={classNames(
'ls-textarea',
className,
{ 'ion-touched': meta.touched },
{ 'ion-invalid': meta.error },
{ 'ion-valid': meta.touched && !meta.error },
)}
onIonInput={onInput}
data-testid={testid}
{...field}
{...textareaProps}
errorText={meta.error}
ref={ref}
></IonTextarea>
);
},
);
Textarea.displayName = 'Textarea';

export default Textarea;
100 changes: 100 additions & 0 deletions src/common/components/Input/__tests__/Textarea.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { TextareaCustomEvent } from '@ionic/react';
import { Form, Formik } from 'formik';
import { object, string } from 'yup';

import { render, screen } from 'test/test-utils';

import Textarea from '../Textarea';

describe('Textarea', () => {
it('should render successfully', async () => {
// ARRANGE
render(
<Formik initialValues={{ testField: '' }} onSubmit={() => {}}>
<Form>
<Textarea name="testField" />
</Form>
</Formik>,
);
await screen.findByTestId('textarea');

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

it('should change value when typing', async () => {
// ARRANGE
const value = 'hello';
render(
<Formik initialValues={{ testField: '' }} onSubmit={() => {}}>
<Form>
<Textarea name="testField" label="Field" />
</Form>
</Formik>,
);
await screen.findByLabelText('Field');

// ACT
await userEvent.type(screen.getByLabelText('Field'), value);

// ASSERT
expect(screen.getByTestId('textarea')).toBeDefined();
expect(screen.getByTestId('textarea')).toHaveValue(value);
});

it('should call supplied input change function', async () => {
// ARRANGE
const value = 'hello';
let enteredValue: string | null | undefined = '';
const onInput = (e: TextareaCustomEvent) => {
enteredValue = e.detail.value;
};
render(
<Formik initialValues={{ testField: '' }} onSubmit={() => {}}>
<Form>
<Textarea name="testField" label="Field" onIonInput={onInput} />
</Form>
</Formik>,
);
await screen.findByLabelText('Field');

// ACT
await userEvent.type(screen.getByLabelText('Field'), value);

// ASSERT
expect(screen.getByTestId('textarea')).toBeDefined();
expect(screen.getByTestId('textarea')).toHaveValue(value);
expect(enteredValue).toBe(value);
});

it('should display error text', async () => {
// ARRANGE
const value = 'hello';
const validationSchema = object({
testField: string().max(4, 'Must be 4 characters or less.'),
});
render(
<Formik
initialValues={{ testField: '' }}
onSubmit={() => {}}
validationSchema={validationSchema}
>
<Form>
<Textarea name="testField" label="Field" />
</Form>
</Formik>,
);
await screen.findByLabelText('Field');

// ACT
await userEvent.type(screen.getByLabelText('Field'), value);
await screen.findByText('Must be 4 characters or less.');

// ASSERT
expect(screen.getByTestId('textarea')).toBeDefined();
expect(screen.getByTestId('textarea')).toHaveValue(value);
expect(screen.getByText('Must be 4 characters or less.')).toBeDefined();
});
});
8 changes: 8 additions & 0 deletions src/common/models/profile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { User } from './user';

/**
* The [user] `Profile` type.
*/
export type Profile = Pick<User, 'email' | 'name'> & {
bio?: string;
};
2 changes: 2 additions & 0 deletions src/common/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { Settings } from 'common/models/settings';
export enum QueryKey {
AppInfo = 'AppInfo',
Settings = 'Settings',
UserProfile = 'UserProfile',
Users = 'Users',
UserTokens = 'UserTokens',
}
Expand All @@ -15,6 +16,7 @@ export enum QueryKey {
*/
export enum StorageKey {
Settings = 'ionic-playground.settings',
UserProfile = 'ionic-playground.user-profile',
User = 'ionic-playground.user',
UserTokens = 'ionic-playground.user-tokens',
}
Expand Down
74 changes: 74 additions & 0 deletions src/pages/Account/api/__tests__/useGetProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { describe, expect, it, vi } from 'vitest';

import { renderHook, waitFor } from 'test/test-utils';
import storage from 'common/utils/storage';
import { StorageKey } from 'common/utils/constants';
import { profileFixture1 } from '__fixtures__/profiles';
import { userFixture1 } from '__fixtures__/users';

import { useGetProfile } from '../useGetProfile';

describe('useGetProfile', () => {
afterEach(() => {
vi.restoreAllMocks();
});

it('should get profile', async () => {
// ARRANGE
const getItemSpy = vi.spyOn(storage, 'getItem');
getItemSpy.mockReturnValue(JSON.stringify(profileFixture1));
const { result } = renderHook(() => useGetProfile());
await waitFor(() => expect(result.current.isSuccess).toBe(true));

// ASSERT
expect(result.current.isSuccess).toBe(true);
expect(result.current.data).toEqual(profileFixture1);
});

it('should initialize profile from current user', async () => {
// ARRANGE
const getItemSpy = vi.spyOn(storage, 'getItem');
// no stored profile; use current user
getItemSpy.mockImplementation((key: StorageKey) => {
if (key == StorageKey.UserProfile) return null;
if (key == StorageKey.User) return JSON.stringify(userFixture1);
return null;
});
const { result } = renderHook(() => useGetProfile());
await waitFor(() => expect(result.current.isSuccess).toBe(true));

// ASSERT
expect(result.current.isSuccess).toBe(true);
expect(result.current.data?.name).toBe(userFixture1.name);
expect(result.current.data?.email).toBe(userFixture1.email);
expect(result.current.data?.bio).toBeUndefined();
});

it('should error with profile not found', async () => {
// ARRANGE
const getItemSpy = vi.spyOn(storage, 'getItem');
// no stored profile; use current user
getItemSpy.mockImplementation(() => {
return null;
});
const { result } = renderHook(() => useGetProfile());
await waitFor(() => expect(result.current.isError).toBe(true));

// ASSERT
expect(result.current.isError).toBe(true);
expect(result.current.error).toBe('Profile not found.');
});

it('should return error', async () => {
// ARRANGE
const getItemSpy = vi.spyOn(storage, 'getItem');
getItemSpy.mockImplementation(() => {
throw new Error('test');
});
const { result } = renderHook(() => useGetProfile());
await waitFor(() => expect(result.current.isError).toBe(true));

// ASSERT
expect(result.current.isError).toBe(true);
});
});
Loading