Skip to content

Commit

Permalink
44 Edit profile (#61)
Browse files Browse the repository at this point in the history
* ProfilePage and ProfileForm components

* input ref; profile form autofocus

* profilepage error and loading states

* styles

* tests

* tests

* tests

* fixes

* buttonrow component
  • Loading branch information
mwarman authored Aug 24, 2024
1 parent aa19637 commit 067c74f
Show file tree
Hide file tree
Showing 15 changed files with 650 additions and 23 deletions.
7 changes: 7 additions & 0 deletions src/common/components/Button/ButtonRow.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.button-row {
&.button-row-block {
ion-button {
flex-grow: 1;
}
}
}
40 changes: 40 additions & 0 deletions src/common/components/Button/ButtonRow.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { IonRow } from '@ionic/react';
import { ComponentPropsWithoutRef } from 'react';
import classNames from 'classnames';

import './ButtonRow.scss';
import { BaseComponentProps } from '../types';

/**
* Properties for the `ButtonRow` component.
* @see {@link BaseComponentProps}
* @see {@link IonRow}
*/
interface ButtonRowProps extends BaseComponentProps, ComponentPropsWithoutRef<typeof IonRow> {
expand?: 'block';
}

/**
* The `ButtonRow` component renders an `IonRow` for the display of one or more
* `IonButton` components in a horizontal row.
*
* Use the `expand` property control how buttons are displayed within the row.
* @param {ButtonRowProps} props - Component properties.
* @returns {JSX.Element} JSX
*/
const ButtonRow = ({
className,
expand,
testid = 'row-button',
...rowProps
}: ButtonRowProps): JSX.Element => {
return (
<IonRow
className={classNames('button-row', { 'button-row-block': expand === 'block' }, className)}
data-testid={testid}
{...rowProps}
/>
);
};

export default ButtonRow;
21 changes: 21 additions & 0 deletions src/common/components/Button/__tests__/ButtonRow.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { describe, expect } from 'vitest';

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

import ButtonRow from '../ButtonRow';

describe('ButtonRow', () => {
it('should render successfully', async () => {
// ARRANGE
render(
<ButtonRow>
<div data-testid="children"></div>
</ButtonRow>,
);
await screen.findByTestId('row-button');

// ASSERT
expect(screen.getByTestId('row-button')).toBeDefined();
expect(screen.getByTestId('children')).toBeDefined();
});
});
50 changes: 30 additions & 20 deletions src/common/components/Input/Input.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import classNames from 'classnames';

import { BaseComponentProps } from '../types';
import { useField } from 'formik';
import { forwardRef } from 'react';

/**
* Properties for the `Input` component.
Expand All @@ -17,29 +18,38 @@ interface InputProps
/**
* The `Input` component renders a standardized `IonInput` which is integrated
* with Formik.
*
* Optionally accepts a forwarded `ref` which allows the parent to manipulate
* the input, performing actions programmatically such as giving focus.
*
* @param {InputProps} props - Component properties.
* @param {ForwardedRef<HTMLIonInputElement>} [ref] - Optional. A forwarded `ref`.
* @returns {JSX.Element} JSX
*/
const Input = ({ className, testid = 'input', ...props }: InputProps): JSX.Element => {
const [field, meta, helpers] = useField(props.name);
const Input = forwardRef<HTMLIonInputElement, InputProps>(
({ className, testid = 'input', ...props }: InputProps, ref): JSX.Element => {
const [field, meta, helpers] = useField(props.name);

return (
<IonInput
className={classNames(
className,
{ 'ion-touched': meta.touched },
{ 'ion-invalid': meta.error },
{ 'ion-valid': meta.touched && !meta.error },
)}
onIonInput={async (e: CustomEvent<InputInputEventDetail>) =>
await helpers.setValue(e.detail.value)
}
data-testid={testid}
{...field}
{...props}
errorText={meta.error}
></IonInput>
);
};
return (
<IonInput
className={classNames(
className,
{ 'ion-touched': meta.touched },
{ 'ion-invalid': meta.error },
{ 'ion-valid': meta.touched && !meta.error },
)}
onIonInput={async (e: CustomEvent<InputInputEventDetail>) =>
await helpers.setValue(e.detail.value)
}
data-testid={testid}
{...field}
{...props}
errorText={meta.error}
ref={ref}
></IonInput>
);
},
);
Input.displayName = 'Input';

export default Input;
4 changes: 4 additions & 0 deletions src/common/components/Router/TabNavigation.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import UserListPage from 'pages/Users/components/UserList/UserListPage';
import UserEditPage from 'pages/Users/components/UserEdit/UserEditPage';
import UserAddPage from 'pages/Users/components/UserAdd/UserAddPage';
import AccountPage from 'pages/Account/AccountPage';
import ProfilePage from 'pages/Account/components/Profile/ProfilePage';

/**
* The `TabNavigation` component provides a router outlet for all of the
Expand Down Expand Up @@ -52,6 +53,9 @@ const TabNavigation = (): JSX.Element => {
<Route exact path="/tabs/account">
<AccountPage />
</Route>
<Route exact path="/tabs/account/profile">
<ProfilePage />
</Route>
<Route exact path="/">
<Redirect to="/tabs/home" />
</Route>
Expand Down
2 changes: 1 addition & 1 deletion src/pages/Account/AccountPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ const AccountPage = ({ testid = 'page-account' }: PropsWithTestId): JSX.Element
<IonListHeader>
<IonLabel>Account</IonLabel>
</IonListHeader>
<IonItem lines="full">
<IonItem lines="full" routerLink="/tabs/account/profile">
<IonLabel>Profile</IonLabel>
</IonItem>
<IonItem lines="full" routerLink="/auth/signout">
Expand Down
97 changes: 97 additions & 0 deletions src/pages/Account/api/__tests__/useUpdateProfile.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { afterAll, describe, expect, it, vi } from 'vitest';

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

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

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

afterAll(() => {
storage.removeItem(StorageKey.User);
});

it('should update profile', async () => {
// ARRANGE
let isSuccess = false;
storage.setItem(StorageKey.User, JSON.stringify(userFixture1));
const { result } = renderHook(() => useUpdateProfile());
await waitFor(() => expect(result.current).not.toBeNull());

// ACT
result.current.mutate(
{ profile: userFixture1 },
{
onSuccess: () => {
isSuccess = true;
},
},
);
await waitFor(() => expect(result.current.isSuccess).toBe(true));

// ASSERT
expect(isSuccess).toBe(true);
});

it('should error when no stored profile', async () => {
// ARRANGE
let isSuccess = false;
let isError = false;
storage.removeItem(StorageKey.User);
const { result } = renderHook(() => useUpdateProfile());
await waitFor(() => expect(result.current).not.toBeNull());

// ACT
result.current.mutate(
{ profile: userFixture1 },
{
onSuccess: () => {
isSuccess = true;
},
onError: () => {
isError = true;
},
},
);
await waitFor(() => expect(result.current.isError).toBe(true));

// ASSERT
expect(isSuccess).toBe(false);
expect(isError).toBe(true);
});

it('should error when an error is caught in mutation function', async () => {
// ARRANGE
let isSuccess = false;
let isError = false;
const getItemSpy = vi.spyOn(storage, 'getItem');
getItemSpy.mockImplementation(() => {
throw new Error('test');
});
const { result } = renderHook(() => useUpdateProfile());
await waitFor(() => expect(result.current).not.toBeNull());

// ACT
result.current.mutate(
{ profile: userFixture1 },
{
onSuccess: () => {
isSuccess = true;
},
onError: () => {
isError = true;
},
},
);
await waitFor(() => expect(result.current.isError).toBe(true));

// ASSERT
expect(isSuccess).toBe(false);
expect(isError).toBe(true);
});
});
57 changes: 57 additions & 0 deletions src/pages/Account/api/useUpdateProfile.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';

import { User } from 'common/models/user';
import { QueryKey, StorageKey } from 'common/utils/constants';
import storage from 'common/utils/storage';

/**
* The `Profile` object. This is a contrived type for demonstration purposes.
*/
export type Profile = Pick<User, 'email' | 'name' | 'phone' | 'username' | 'website'>;

/**
* The `useUpdateProfile` mutatation function variables.
* @param {Profile} profile - The updated `Profile` object.
*/
export type UpdateProfileVariables = {
profile: Profile;
};

/**
* An API hook which updates a single `Profile`. Returns a `UseMutationResult`
* object whose `mutate` attribute is a function to update a `Profile`.
*
* When successful, the hook updates the cached `Profile` query data.
* @returns Returns a `UseMutationResult`.
*/
export const useUpdateProfile = () => {
const queryClient = useQueryClient();

const updateProfile = ({ profile }: UpdateProfileVariables): Promise<User> => {
return new Promise((resolve, reject) => {
try {
const storedProfile = storage.getItem(StorageKey.User);
if (storedProfile) {
const currentProfile: User = JSON.parse(storedProfile);
const updatedProfile: User = { ...currentProfile, ...profile };
storage.setItem(StorageKey.User, JSON.stringify(updatedProfile));
return resolve(updatedProfile);
} else {
return reject(new Error('Profile not found.'));
}
} catch (err) {
return reject(err);
}
});
};

return useMutation({
mutationFn: updateProfile,
onSuccess: (data) => {
// update cached query data
queryClient.setQueryData<User>([QueryKey.Users, 'current'], data);
// you may [also|instead] choose to invalidate certain cached queries, triggering refetch
// queryClient.invalidateQueries({ queryKey: [QueryKey.Users, 'current'] });
},
});
};
5 changes: 5 additions & 0 deletions src/pages/Account/components/Profile/ProfileForm.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
.form-profile {
ion-input {
margin-bottom: 0.5rem;
}
}
Loading

0 comments on commit 067c74f

Please sign in to comment.