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
59 changes: 59 additions & 0 deletions src/pages/Users/api/__tests__/useDeleteUser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest';

import { renderHook, waitFor } from 'test/test-utils';
import { queryClient } from 'test/query-client';
import { QueryKey } from 'common/utils/constants';
import { userFixture1, usersFixture } from '__fixtures__/users';

import { useDeleteUser } from '../useDeleteUser';
import { User } from 'common/models/user';

describe('useDeleteUser', () => {
it('should delete user', async () => {
// ARRANGE
let isSuccess = false;
const { result } = renderHook(() => useDeleteUser());
await waitFor(() => expect(result.current).not.toBeNull());

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

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

it('should update cached data when exists', async () => {
// ARRANGE
const userId = usersFixture[0].id;
const originalSize = usersFixture.length;
queryClient.setQueryData([QueryKey.Users], usersFixture);
let isSuccess = false;
const { result } = renderHook(() => useDeleteUser());
await waitFor(() => expect(result.current).not.toBeNull());

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

// ASSERT
expect(isSuccess).toBe(true);
const cachedUsers = queryClient.getQueryData<User[]>([QueryKey.Users]);
expect(cachedUsers?.length).toBe(originalSize - 1);
expect(cachedUsers?.find((user) => user.id === userId)).toBeUndefined();
});
});
51 changes: 51 additions & 0 deletions src/pages/Users/api/useDeleteUser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import axios from 'axios';
import reject from 'lodash/reject';

import { useConfig } from 'common/hooks/useConfig';
import { User } from 'common/models/user';
import { QueryKey } from 'common/utils/constants';

/**
* The `useDeleteUser` mutation function variables.
* @param {number} id - A `User` identifier.
*/
export type DeleteUserVariables = {
id: number;
};

/**
* An API hook which deletes a single `User`. Returns a `UseMutationResult`
* object whose `mutate` attribute is a function to delete a `User`.
*
* When successful, thehook updates the cached `User` data.
*
* @returns Returns a `UseMutationResult`.
*/
export const useDeleteUser = () => {
const queryClient = useQueryClient();
const config = useConfig();

/**
* Delete a `User`.
* @param {DeleteUserVariables} variables - The mutation function variables.
* @returns {Promise<void>} A Promise which resolves `void` when successful.
*/
const deleteUser = async ({ id }: DeleteUserVariables): Promise<void> => {
const response = await axios.request({
method: 'delete',
url: `${config.VITE_BASE_URL_API}/users/${id}`,
});

return response.data;
};

return useMutation({
mutationFn: deleteUser,
onSuccess: (data, variables) => {
queryClient.setQueryData<User[]>([QueryKey.Users], (cachedUsers) =>
cachedUsers ? [...reject(cachedUsers, { id: variables.id })] : [],
);
},
});
};
138 changes: 122 additions & 16 deletions src/pages/Users/components/UserDetail/UserDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,16 +1,35 @@
import { IonButton, IonContent, IonIcon, IonPage } from '@ionic/react';
import {
IonAlert,
IonButton,
IonContent,
IonIcon,
IonPage,
IonProgressBar,
useIonRouter,
} from '@ionic/react';
import { useState } from 'react';
import { useParams } from 'react-router';
import { create } from 'ionicons/icons';
import { create, trash } from 'ionicons/icons';
import classNames from 'classnames';

import './UserDetailPage.scss';
import { PropsWithTestId } from 'common/components/types';
import { useGetUser } from 'pages/Users/api/useGetUser';
import { useDeleteUser } from 'pages/Users/api/useDeleteUser';
import { useToasts } from 'common/hooks/useToasts';
import { DismissButton } from 'common/components/Toast/Toast';
import Header from 'common/components/Header/Header';
import UserDetail from './UserDetail';
import Container from 'common/components/Content/Container';
import PageHeader from 'common/components/Content/PageHeader';
import Avatar from 'common/components/Icon/Avatar';

/**
* Properties for the `UserDetailPage` component.
* @see {@link PropsWithTestId}
*/
interface UserDetailPageProps extends PropsWithTestId {}

/**
* Router path parameters for the `UserDetailPage`.
* @param {string} userId - A user identifier.
Expand All @@ -23,27 +42,69 @@ interface UserDetailPageRouteParams {
* The `UserDetailPage` component renders information about a single `User`.
* @returns JSX
*/
export const UserDetailPage = (): JSX.Element => {
const testid = 'page-user-detail';
export const UserDetailPage = ({
testid = 'page-user-detail',
}: UserDetailPageProps): JSX.Element => {
const { createToast } = useToasts();
const { isPending: isDeleting, mutate: deleteUser } = useDeleteUser();
const [showConfirmDelete, setShowConfirmDelete] = useState<boolean>(false);
const { userId } = useParams<UserDetailPageRouteParams>();
const router = useIonRouter();
const { data: user } = useGetUser({ userId });

const doDeleteUser = (userId?: number) => {
if (userId) {
deleteUser(
{ id: userId },
{
onSuccess: () => {
setShowConfirmDelete(false);
createToast({
buttons: [DismissButton],
duration: 5000,
message: `${user?.name} deleted`,
});
router.goBack();
},
onError: () => {
setShowConfirmDelete(false);
//TODO: display delete user error state
},
},
);
}
};

return (
<IonPage className={'page-user-detail'} data-testid={testid}>
<Header
backButton
buttons={
<IonButton
title="Edit user"
className="ion-hide-md-up"
routerLink={`/tabs/users/${userId}/edit`}
>
<IonIcon slot="icon-only" icon={create} />
</IonButton>
user && (
<>
<IonButton
title="Edit user"
className="ion-hide-md-up"
routerLink={`/tabs/users/${userId}/edit`}
data-testid={`${testid}-header-button-edit`}
>
<IonIcon slot="icon-only" icon={create} />
</IonButton>
<IonButton
title="Delete user"
className="ion-hide-md-up"
onClick={() => setShowConfirmDelete(true)}
data-testid={`${testid}-header-button-delete`}
>
<IonIcon slot="icon-only" icon={trash} />
</IonButton>
</>
)
}
defaultHref="/tabs/users"
title={user && user.name}
/>
{isDeleting && <IonProgressBar type="indeterminate"></IonProgressBar>}

<IonContent className="ion-padding">
<Container fixed>
Expand All @@ -53,20 +114,65 @@ export const UserDetailPage = (): JSX.Element => {
user ? (
<div className={'title-block'}>
<Avatar value={user.name} />
<div>{user.name}</div>
<div data-testid={`${testid}-page-header-title`}>{user.name}</div>
</div>
) : (
'User Detail'
<div data-testid={`${testid}-page-header-title`}>User Detail</div>
)
}
buttons={
<IonButton title="Edit user" routerLink={`/tabs/users/${userId}/edit`}>
<IonIcon slot="icon-only" icon={create} />
</IonButton>
user && (
<>
<IonButton
title="Edit user"
routerLink={`/tabs/users/${userId}/edit`}
data-testid={`${testid}-page-header-button-edit`}
>
<IonIcon slot="icon-only" icon={create} />
</IonButton>
<IonButton
title="Delete user"
onClick={() => setShowConfirmDelete(true)}
data-testid={`${testid}-page-header-button-delete`}
>
<IonIcon slot="icon-only" icon={trash} />
</IonButton>
</>
)
}
/>

<UserDetail testid={`${testid}-user-detail`} userId={userId} />

<IonAlert
buttons={[
{
handler: () => {
setShowConfirmDelete(false);
},
htmlAttributes: {
disabled: isDeleting,
},
text: 'Cancel',
},
{
handler: () => {
doDeleteUser(user?.id);
return false;
},
htmlAttributes: { disabled: isDeleting },
text: 'Delete',
},
]}
className="alert-delete"
header={isDeleting ? 'Deleting...' : 'Are you sure?'}
isOpen={showConfirmDelete}
message={
isDeleting
? `Deleting ${user?.name} in progress.`
: `Deleting ${user?.name} is permanent.`
}
/>
</Container>
</IonContent>
</IonPage>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { describe, expect, it, vi } from 'vitest';
import { UseQueryResult } from '@tanstack/react-query';

import { render, screen } from 'test/test-utils';
import { userFixture1 } from '__fixtures__/users';
import * as UseGetUser from 'pages/Users/api/useGetUser';
import { User } from 'common/models/user';

import UserDetailPage from '../UserDetailPage';

Expand All @@ -28,10 +31,34 @@ describe('UserDetailPage', () => {

it('should render user details', async () => {
// ARRANGE
render(<UserDetailPage />);
await screen.findByTestId('page-user-detail-user-detail');
render(<UserDetailPage testid="test" />);
await screen.findByTestId('test-header-button-edit');

// ASSERT
expect(screen.getByTestId('test-header-button-edit')).toBeDefined();
expect(screen.getByTestId('test-header-button-delete')).toBeDefined();
expect(screen.getByTestId('test-page-header-button-edit')).toBeDefined();
expect(screen.getByTestId('test-page-header-button-delete')).toBeDefined();
expect(screen.getByTestId('test-page-header-title')).toHaveTextContent(userFixture1.name);
});

it('should render when no user', async () => {
// ARRANGE
const useGetUserSpy = vi.spyOn(UseGetUser, 'useGetUser');
useGetUserSpy.mockReturnValue({
data: undefined,
isPending: true,
isSuccess: false,
} as unknown as UseQueryResult<User>);
render(<UserDetailPage testid="test" />);
await screen.findByTestId('test');

// ASSERT
expect(screen.getByTestId('page-user-detail-user-detail')).toBeDefined();
expect(screen.getByTestId('test')).toBeDefined();
expect(screen.queryByTestId('test-header-button-edit')).toBeNull();
expect(screen.queryByTestId('test-header-button-delete')).toBeNull();
expect(screen.queryByTestId('test-page-header-button-edit')).toBeNull();
expect(screen.queryByTestId('test-page-header-button-delete')).toBeNull();
expect(screen.getByTestId('test-page-header-title')).toHaveTextContent('User Detail');
});
});
9 changes: 9 additions & 0 deletions src/test/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,13 @@ export const handlers = [
}
return new HttpResponse(null, { status: 404 });
}),
http.delete('https://jsonplaceholder.typicode.com/users/:userId', async ({ params }) => {
// delete a user
const { userId } = params;
const user = find(usersFixture, { id: Number(userId) });
if (user) {
return new HttpResponse(null, { status: 200 });
}
return new HttpResponse(null, { status: 404 });
}),
];