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

44 Edit profile #61

Merged
merged 9 commits into from
Aug 24, 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
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