Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

59 Textarea input #76

Merged
merged 15 commits into from
Sep 9, 2024
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: '[email protected]',
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