feat(frontend): add tests form expense list and items

This commit is contained in:
Sergey Krylov 2025-09-03 06:22:06 +03:00
parent 9cb8c376dd
commit 06897a86db
41 changed files with 1724 additions and 241 deletions

View File

@ -15,6 +15,9 @@ export default defineConfig({
}),
pluginTypeCheck(),
],
html: {
title: 'Investments Control',
},
server: {
proxy: {
'/api': {

View File

@ -2,4 +2,16 @@ export { useAddExpenseItem } from './hooks/useAddExpenseItem';
export { useDeleteExpenseItem } from './hooks/useDeleteExpenseItem';
export { useEditExpenseItem } from './hooks/useEditExpenseItem';
export type { AddExpenseItemSuccessResponse, AddExpenseItemBody, AddExpenseItemPath } from './api/addExpenseItem';
export type { DeleteExpenseItemPath, DeleteExpenseItemSuccessResponse } from './api/deleteExpenseItem';
export type { EditExpenseItemBody, EditExpenseItemPath, EditExpenseItemSuccessResponse } from './api/editExpenseItem';
export type { AddExpenseItemFormValues } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
export { default as AddExpenseItemForm } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
export type { EditExpenseItemFormValues } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
export { default as EditExpenseItemForm } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
export { default as DeleteExpenseItemForm } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
export type { ExpenseListItem } from './types';

View File

@ -0,0 +1,76 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddExpenseItemForm from './AddExpenseItemForm.ui';
import type { AddExpenseItemFormProps } from './AddExpenseItemForm.ui';
const renderForm = (props?: Partial<AddExpenseItemFormProps>) => {
const onSubmit = jest.fn();
render(<AddExpenseItemForm data-testid="add-expense-item-form" onSubmit={onSubmit} {...props} />);
const titleInputElem = screen.getByLabelText('Название');
const descriptionInputElem = screen.getByLabelText('Описание');
const dateInputElem = screen.getByLabelText('Дата');
const amountInputElem = screen.getByLabelText('Сумма');
const countInputElem = screen.getByLabelText('Количество');
const submitButtonElem = screen.getByText('Добавить');
return {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
countInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test AddExpenseItemForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('add-expense-item-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => expect(el).toBeEnabled()],
['disabled', { disabled: true }, (el: HTMLElement) => expect(el).toBeDisabled()],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
countInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.type(titleInputElem, 'Название элемента');
await userEvent.type(descriptionInputElem, 'Описание элемента');
await userEvent.type(dateInputElem, '2025-08-29');
await userEvent.type(amountInputElem, '12');
await userEvent.type(countInputElem, '1');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
count: 1,
currency: 'RUB',
});
});
});

View File

@ -0,0 +1,110 @@
import { Input } from '@/shared/ui/Input';
import classes from './AddExpenseItemForm.module.css';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type AddExpenseItemFormValues = {
title: string;
description: string;
date: string;
amount: number;
count: number;
currency: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type AddExpenseItemFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddExpenseItemFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
const {
disabled,
onSubmit,
ref,
...formProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
onSubmit?.({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
count: Number(count),
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...formProps}
>
<h2>Добавить список расходов</h2>
<Input
required
disabled={disabled}
label="Название"
name="title"
/>
<Input
disabled={disabled}
label="Описание"
name="description"
/>
<Input
required
disabled={disabled}
label="Дата"
name="date"
type="date"
/>
<Input
required
disabled={disabled}
label="Сумма"
name="amount"
type="number"
/>
<Input
disabled={disabled}
label="Количество"
name="count"
type="number"
/>
<button disabled={disabled} type="submit">Добавить</button>
</form>
);
};
export default AddExpenseItemForm;

View File

@ -0,0 +1,60 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteExpenseItemForm from './DeleteExpenseItemForm.ui';
import type { DeleteExpenseItemFormProps } from './DeleteExpenseItemForm.ui';
const deletingItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
count: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderForm = (props?: Partial<DeleteExpenseItemFormProps>) => {
const onSubmit = jest.fn();
render(
<DeleteExpenseItemForm
data-testid="delete-expense-item-form"
item={deletingItem}
onSubmit={onSubmit}
{...props}
/>,
);
const submitButtonElem = screen.getByText('Удалить');
return {
submitButtonElem,
onSubmit,
};
};
describe('Test DeleteExpenseItemForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('delete-expense-item-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should call onSubmit with values', async () => {
const {
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(deletingItem);
});
});

View File

@ -0,0 +1,40 @@
import type { ExpenseListItem } from '../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type DeleteExpenseItemFormProps = BaseFormProps & {
item: ExpenseListItem;
onSubmit?: (item: ExpenseListItem) => Promise<void> | void;
disabled?: boolean;
ref?: RefObject<HTMLFormElement>;
};
const DeleteExpenseItemForm: FC<DeleteExpenseItemFormProps> = (props) => {
const {
item,
onSubmit,
ref,
disabled,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(item);
};
return (
<form ref={ref} onSubmit={onFormSubmitHandler} {...restProps}>
<h2>Удалить элемент ?</h2>
<button disabled={disabled} type="submit">Удалить</button>
</form>
);
};
export default DeleteExpenseItemForm;

View File

@ -0,0 +1,120 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditExpenseItemForm from './EditExpenseItemForm.ui';
import type { EditExpenseItemFormProps } from './EditExpenseItemForm.ui';
const editingItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
count: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderForm = (props?: Partial<EditExpenseItemFormProps>) => {
const onSubmit = jest.fn();
render(
<EditExpenseItemForm
data-testid="edit-expense-item-form"
item={editingItem}
onSubmit={onSubmit}
{...props}
/>,
);
const titleInputElem = screen.getByLabelText('Название');
const descriptionInputElem = screen.getByLabelText('Описание');
const dateInputElem = screen.getByLabelText('Дата');
const amountInputElem = screen.getByLabelText('Сумма');
const countInputElem = screen.getByLabelText('Количество');
const submitButtonElem = screen.getByText('Сохранить');
return {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
countInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test EditExpenseItemForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('edit-expense-item-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should inputs have default value', () => {
const {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
countInputElem,
} = renderForm();
expect(titleInputElem).toHaveValue('Mock item');
expect(descriptionInputElem).toHaveValue('Mock description');
expect(dateInputElem).toHaveValue('2025-08-22');
expect(amountInputElem).toHaveValue(12);
expect(countInputElem).toHaveValue(12);
});
test.each([
['enabled', {}, (el: HTMLElement) => expect(el).toBeEnabled()],
['disabled', { disabled: true }, (el: HTMLElement) => expect(el).toBeDisabled()],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
descriptionInputElem,
dateInputElem,
amountInputElem,
countInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, 'Название элемента');
await userEvent.clear(descriptionInputElem);
await userEvent.type(descriptionInputElem, 'Описание элемента');
await userEvent.clear(dateInputElem);
await userEvent.type(dateInputElem, '2025-08-29');
await userEvent.clear(amountInputElem);
await userEvent.type(amountInputElem, '12');
await userEvent.clear(countInputElem);
await userEvent.type(countInputElem, '1');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
count: 1,
currency: 'RUB',
});
});
});

View File

@ -0,0 +1,113 @@
import { Input } from '@/shared/ui/Input';
import classes from './EditExpenseItemForm.module.css';
import type { ExpenseListItem } from '../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type EditExpenseItemFormValues = {
title: string;
description: string;
date: string;
amount: number;
count: number;
currency: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type EditExpenseItemFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: EditExpenseItemFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
item: ExpenseListItem;
};
const EditExpenseItemForm: FC<EditExpenseItemFormProps> = (props) => {
const {
disabled,
onSubmit,
ref,
item,
...formProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
await onSubmit?.({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
count: Number(count),
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...formProps}
>
<h2>Добавить список расходов</h2>
<Input
defaultValue={item.title}
disabled={disabled}
label="Название"
name="title"
/>
<Input
defaultValue={item.description ?? ''}
disabled={disabled}
label="Описание"
name="description"
/>
<Input
defaultValue={new Date(item.date).toISOString().split('T')[0]}
disabled={disabled}
label="Дата"
name="date"
type="date"
/>
<Input
defaultValue={item.amount}
disabled={disabled}
label="Сумма"
name="amount"
type="number"
/>
<Input
defaultValue={item.count ?? 1}
disabled={disabled}
label="Количество"
name="count"
type="number"
/>
<button disabled={disabled} type="submit">Сохранить</button>
</form>
);
};
export default EditExpenseItemForm;

View File

@ -2,8 +2,9 @@ import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type AddExpenseListBody = paths['/expenses/list']['post']['requestBody']['content']['application/json'];
type AddExpenseListSuccessResponse = paths['/expenses/list']['post']['responses']['200']['content']['application/json'];
export type AddExpenseListPath = paths['/expenses/list']['post']['parameters']['path'];
export type AddExpenseListBody = paths['/expenses/list']['post']['requestBody']['content']['application/json'];
export type AddExpenseListSuccessResponse = paths['/expenses/list']['post']['responses']['200']['content']['application/json'];
export async function addExpensesList(data: AddExpenseListBody) {
const { data: result } = await api.post<AddExpenseListSuccessResponse>('/expenses/list', data);

View File

@ -2,8 +2,9 @@ import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type DeleteExpenseListPath = paths['/expenses/list/{id}']['delete']['parameters']['path'];
type DeleteExpenseSuccessResponse = paths['/expenses/list/{id}']['delete']['responses']['200']['content']['application/json'];
export type DeleteExpenseListPath = paths['/expenses/list/{id}']['delete']['parameters']['path'];
export type DeleteExpenseSuccessResponse = paths['/expenses/list/{id}']['delete']['responses']['200']['content']['application/json'];
export async function deleteExpensesList(data: DeleteExpenseListPath) {
const { id } = data;
const { data: result } = await api.delete<DeleteExpenseSuccessResponse>(`/expenses/list/${id}`);

View File

@ -2,9 +2,9 @@ import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type EditExpenseListPath = paths['/expenses/list/{id}']['patch']['parameters']['path'];
type EditExpenseListBody = paths['/expenses/list/{id}']['patch']['requestBody']['content']['application/json'];
type EditExpenseListSuccessResponse = paths['/expenses/list/{id}']['patch']['responses']['200']['content']['application/json'];
export type EditExpenseListPath = paths['/expenses/list/{id}']['patch']['parameters']['path'];
export type EditExpenseListBody = paths['/expenses/list/{id}']['patch']['requestBody']['content']['application/json'];
export type EditExpenseListSuccessResponse = paths['/expenses/list/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editExpensesList({ data, path }: {
path: EditExpenseListPath;

View File

@ -5,3 +5,15 @@ export { useExpensesList } from './hooks/useExpensesList';
export { useEditExpensesList } from './hooks/useEditExpensesList';
export { useExpenseList } from './hooks/useExpenseList';
export type { AddExpenseListFormValues } from './ui/AddExpensesListForm/ui/AddExpensesListForm.ui';
export { default as AddExpensesListForm } from './ui/AddExpensesListForm/ui/AddExpensesListForm.ui';
export { default as DeleteExpensesListForm } from './ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.ui';
export type { EditExpenseListFormValues } from './ui/EditExpensesListForm/ui/EditExpensesListForm.ui';
export { default as EditExpensesListForm } from './ui/EditExpensesListForm/ui/EditExpensesListForm.ui';
export type { AddExpenseListPath, AddExpenseListBody, AddExpenseListSuccessResponse } from './api/addExpensesList';
export type { DeleteExpenseListPath, DeleteExpenseSuccessResponse } from './api/deleteExpensesList';
export type { EditExpenseListPath, EditExpenseListBody, EditExpenseListSuccessResponse } from './api/editExpensesList';

View File

@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,54 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddExpensesListForm from './AddExpensesListForm.ui';
import type { AddExpenseListFormProps } from './AddExpensesListForm.ui';
const renderForm = (props?: Partial<AddExpenseListFormProps>) => {
const onSubmit = jest.fn();
render(<AddExpensesListForm data-testid="add-expense-list-form" onSubmit={onSubmit} {...props} />);
const titleInputElem = screen.getByLabelText('Название');
const submitButtonElem = screen.getByText('Добавить');
return {
titleInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test AddExpensesListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('add-expense-list-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => expect(el).toBeEnabled()],
['disabled', { disabled: true }, (el: HTMLElement) => expect(el).toBeDisabled()],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.type(titleInputElem, 'Название списка');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название списка',
});
});
});

View File

@ -0,0 +1,62 @@
import { Input } from '@/shared/ui/Input';
import classes from './AddExpenseListForm.module.css';
import type {
FormHTMLAttributes,
RefObject,
FormEventHandler,
FC,
} from 'react';
export type AddExpenseListFormValues = {
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type AddExpenseListFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit: (data: AddExpenseListFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const AddExpensesListForm: FC<AddExpenseListFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
onSubmit?.({
title,
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...restProps}
>
<h2>Добавить список расходов</h2>
<Input disabled={disabled} label="Название" name="title" />
<button disabled={disabled} type="submit">Добавить</button>
</form>
);
};
export default AddExpensesListForm;

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteExpensesListForm from './DeleteExpensesListForm.ui';
import type { DeleteExpenseListFormProps } from './DeleteExpensesListForm.ui';
const deletingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial<DeleteExpenseListFormProps>) => {
const onSubmit = jest.fn();
render(
<DeleteExpensesListForm
data-testid="delete-expense-list-form"
list={deletingList}
onSubmit={onSubmit}
{...props}
/>,
);
const submitButtonElem = screen.getByText('Удалить');
return {
submitButtonElem,
onSubmit,
};
};
describe('Test DeleteExpensesListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('delete-expense-list-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should call onSubmit with values', async () => {
const {
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(deletingList);
});
});

View File

@ -0,0 +1,64 @@
import type { ExpenseList } from '@/entity/expenses/list';
import type {
FC, FormEventHandler, FormHTMLAttributes, RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type DeleteExpenseListFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (list: ExpenseList) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
list: ExpenseList;
};
const DeleteExpensesListForm: FC<DeleteExpenseListFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
list,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(list);
};
return (
<form ref={ref} onSubmit={onFormSubmitHandler} {...restProps}>
<h2>Удалить список расходов</h2>
<p>
Вы действительно хотите удалить список расходов
{' '}
<b>{`"${list.title}"`}</b>
{' '}
?
</p>
{
list?.items && list?.items?.length > 0
? (
<p>
В нем содержится
{' '}
<b>{list.items?.length}</b>
{' '}
расходов
</p>
)
: null
}
<button disabled={disabled} type="submit">Удалить</button>
</form>
);
};
export default DeleteExpensesListForm;

View File

@ -0,0 +1,5 @@
.form {
display: flex;
flex-direction: column;
gap: 10px;
}

View File

@ -0,0 +1,78 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditExpensesListForm from './EditExpensesListForm.ui';
import type { EditExpensesListFormProps } from './EditExpensesListForm.ui';
const editingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial<EditExpensesListFormProps>) => {
const onSubmit = jest.fn();
render(
<EditExpensesListForm
data-testid="edit-expense-list-form"
list={editingList}
onSubmit={onSubmit}
{...props}
/>,
);
const titleInputElem = screen.getByLabelText('Название');
const submitButtonElem = screen.getByText('Сохранить');
return {
titleInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test EditExpensesListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('edit-expense-list-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should inputs have default value', () => {
const {
titleInputElem,
} = renderForm();
expect(titleInputElem).toHaveValue('Mock list');
});
test.each([
['enabled', {}, (el: HTMLElement) => expect(el).toBeEnabled()],
['disabled', { disabled: true }, (el: HTMLElement) => expect(el).toBeDisabled()],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, 'Название списка');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
title: 'Название списка',
});
});
});

View File

@ -0,0 +1,68 @@
import { Input } from '@/shared/ui/Input';
import classes from './EditExpenseListForm.module.css';
import type { ExpenseList } from '@/entity/expenses/list';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type EditExpenseListFormValues = {
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type EditExpensesListFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit: (data: EditExpenseListFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
list: ExpenseList;
};
const EditExpensesListForm: FC<EditExpensesListFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
list,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
onSubmit({
title,
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...restProps}
>
<h2>Редактировать список расходов</h2>
<Input
defaultValue={list.title}
disabled={disabled}
label="Название"
name="title"
/>
<button disabled={disabled} type="submit">Сохранить</button>
</form>
);
};
export default EditExpensesListForm;

View File

@ -1,42 +1,32 @@
import { type FormEventHandler, useState } from 'react';
import { useState } from 'react';
import { useAddExpenseItem } from '@/entity/expenses/item';
import type { AddExpenseItemFormValues } from '@/entity/expenses/item';
import type { ExpenseList } from '@/entity/expenses/list';
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
type UseAddExpenseItemButtonArgs = {
listId: ExpenseList['id'];
onClick?: ButtonHTMLAttributes<HTMLButtonElement>['onClick'];
};
export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
const { listId } = args;
const { listId, onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addExpenseItemMutation, isPending } = useAddExpenseItem({ id: listId });
const onCloseModalHandler = () => setIsOpen(false);
const onClickHandler = () => setIsOpen(true);
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
setIsOpen(true);
onClick?.(event);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const onFormSubmitHandler = async (data: AddExpenseItemFormValues) => {
// todo добавить обработку ошибок (код + тест)
await addExpenseItemMutation(data);
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
await addExpenseItemMutation({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
count: Number(count),
});
setIsOpen(false);
}
};
return {

View File

@ -0,0 +1,126 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import AddExpenseItemButton from './AddExpenseItemButton.ui';
import type { AddExpenseItemBody, AddExpenseItemPath, AddExpenseItemSuccessResponse } from '@/entity/expenses/item';
const renderButton = () => {
render(
<AddExpenseItemButton data-testid="test-button" listId="123" />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('test-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const fillForm = async (data: { title: string; description: string; date: string; amount: string; count: string }) => {
await userEvent.type(screen.getByLabelText('Название'), data.title);
await userEvent.type(screen.getByLabelText('Описание'), data.description);
await userEvent.type(screen.getByLabelText('Дата'), data.date);
await userEvent.type(screen.getByLabelText('Сумма'), data.amount);
await userEvent.type(screen.getByLabelText('Количество'), data.count);
};
const submitHandler = (requestSpy: jest.Mock) => http.post<
AddExpenseItemPath,
AddExpenseItemBody,
AddExpenseItemSuccessResponse
>(
'http://localhost:3000/expenses/list/123/items',
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json({
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
count: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
});
},
);
describe('Test AddExpenseItemButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Добавить элемент');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Добавить');
await fillForm({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29',
amount: '12',
count: '1',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
count: 1,
currency: 'RUB',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -1,19 +1,25 @@
import { Input } from '@/shared/ui/Input';
import { AddExpenseItemForm } from '@/entity/expenses/item';
import { Modal } from '@/shared/ui/Modal';
import { useAddExpenseItemButton } from '../hooks/useAddExpenseItemButton';
import classes from './AddExpenseItemButton.module.css';
import type { ExpenseList } from '@/entity/expenses/list';
import type { FC } from 'react';
import type { ButtonHTMLAttributes, FC, RefObject } from 'react';
type AddExpenseItemButtonProps = {
type BaseButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type AddExpenseItemButtonProps = BaseButtonProps & {
listId: ExpenseList['id'];
ref?: RefObject<HTMLButtonElement>;
};
const AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
const { listId } = props;
const {
listId,
ref,
onClick,
...buttonProps
} = props;
const {
isOpen,
@ -21,56 +27,21 @@ const AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
} = useAddExpenseItemButton({ listId });
} = useAddExpenseItemButton({ listId, onClick });
return (
<>
<button type="button" onClick={onClickHandler}>
<button
ref={ref}
type="button"
onClick={onClickHandler}
{...buttonProps}
>
Добавить элемент
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form className={classes.form} onSubmit={onFormSubmitHandler}>
<h2>Добавить список расходов</h2>
<Input
required
disabled={isPending}
label="Название"
name="title"
/>
<Input
disabled={isPending}
label="Описание"
name="description"
/>
<Input
required
disabled={isPending}
label="Дата"
name="date"
type="date"
/>
<Input
required
disabled={isPending}
label="Сумма"
name="amount"
type="number"
/>
<Input
disabled={isPending}
label="Количество"
name="count"
type="number"
/>
<button disabled={isPending} type="submit">Добавить</button>
</form>
<AddExpenseItemForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>

View File

@ -1,26 +1,30 @@
import { type FormEventHandler, useState } from 'react';
import { useState } from 'react';
import { type ExpenseListItem, useDeleteExpenseItem } from '@/entity/expenses/item';
import type { MouseEventHandler } from 'react';
type UseDeleteExpenseItemButtonArgs = {
item: ExpenseListItem;
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useDeleteExpenseItemButton = (args: UseDeleteExpenseItemButtonArgs) => {
const { item } = args;
const { item, onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const onCloseModalHandler = () => setIsOpen(false);
const onClickHandler = () => setIsOpen(true);
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
setIsOpen(true);
onClick?.(event);
};
const { mutateAsync: deleteExpenseItemMutation, isPending } = useDeleteExpenseItem({
listId: item.expenseListId,
id: item.id,
});
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const onFormSubmitHandler = async () => {
await deleteExpenseItemMutation();
setIsOpen(false);
};

View File

@ -0,0 +1,79 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import DeleteExpenseItemButton from './DeleteExpenseItemButton.ui';
import type { DeleteExpenseItemPath } from '@/entity/expenses/item';
const deletedItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
count: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderButton = () => {
render(
<DeleteExpenseItemButton data-testid="delete-expense-item-button" item={deletedItem} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('delete-expense-item-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = http.delete<DeleteExpenseItemPath>(`http://localhost:3000/expenses/list/${deletedItem.expenseListId}/items/${deletedItem.id}`, () => HttpResponse.json(deletedItem));
describe('Test DeleteExpenseItemButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Удалить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(submitHandler);
const { modalElem } = await openModal();
const submitButtonElem = within(modalElem).getByText('Удалить');
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});

View File

@ -1,35 +1,45 @@
import { DeleteExpenseItemForm } from '@/entity/expenses/item';
import { Modal } from '@/shared/ui/Modal';
import { useDeleteExpenseItemButton } from '../hooks/useDeleteExpenseItemButton';
import type { ExpenseListItem } from '@/entity/expenses/item';
import type { FC } from 'react';
import type { ButtonHTMLAttributes, FC, RefObject } from 'react';
type DeleteExpenseItemButtonProps = {
type BaseButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type DeleteExpenseItemButtonProps = BaseButtonProps & {
item: ExpenseListItem;
ref?: RefObject<HTMLButtonElement>;
};
const DeleteExpenseItemButton: FC<DeleteExpenseItemButtonProps> = (props) => {
const { item } = props;
const {
item,
ref,
onClick,
...restProps
} = props;
const {
isOpen,
onCloseModalHandler,
onClickHandler,
isPending,
onFormSubmitHandler,
} = useDeleteExpenseItemButton({ item });
} = useDeleteExpenseItemButton({ item, onClick });
return (
<>
<button type="button" onClick={onClickHandler}>
<button
ref={ref}
type="button"
onClick={onClickHandler}
{...restProps}
>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Удалить элемент ?</h2>
<button disabled={isPending} type="submit">Удалить</button>
</form>
<DeleteExpenseItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);

View File

@ -1,12 +1,16 @@
import { type FormEventHandler, useState } from 'react';
import { useState } from 'react';
import { type ExpenseListItem, useEditExpenseItem } from '@/entity/expenses/item';
import { type ExpenseListItem, useEditExpenseItem, type EditExpenseItemFormValues } from '@/entity/expenses/item';
import type { MouseEventHandler } from 'react';
type UseAddExpenseItemButtonArgs = {
item: ExpenseListItem;
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
const { item } = args;
export const useEditExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
const { item, onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editExpenseItemMutation, isPending } = useEditExpenseItem({
@ -15,29 +19,15 @@ export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
});
const onCloseModalHandler = () => setIsOpen(false);
const onClickHandler = () => setIsOpen(true);
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
setIsOpen(true);
onClick?.(event);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
await editExpenseItemMutation({
title,
date: new Date(date).toISOString(),
amount: Number(amount),
description,
currency: 'RUB',
count: Number(count),
});
const onFormSubmitHandler = async (data: EditExpenseItemFormValues) => {
// todo добавить обработку ошибок (код + тест)
await editExpenseItemMutation(data);
setIsOpen(false);
}
};
return {

View File

@ -0,0 +1,146 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import EditExpenseItemButton from './EditExpenseItemButton.ui';
import type {
EditExpenseItemBody,
EditExpenseItemPath,
EditExpenseItemSuccessResponse,
} from '@/entity/expenses/item';
const editingItem = {
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
title: 'Mock item',
amount: 12,
count: 12,
updatedAt: '2025-08-29T07:57:40.503Z',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
createdAt: '2025-08-29T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
description: 'Mock description',
};
const renderButton = () => {
render(
<EditExpenseItemButton data-testid="edit-expense-item-button" item={editingItem} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('edit-expense-item-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = (requestSpy: jest.Mock) => http.patch<
EditExpenseItemPath,
EditExpenseItemBody,
EditExpenseItemSuccessResponse
>(
`http://localhost:3000/expenses/list/${editingItem.expenseListId}/items/${editingItem.id}`,
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json(editingItem);
},
);
const fillForm = async (data: { title: string; description: string; date: string; amount: string; count: string }) => {
const titleInputElem = screen.getByLabelText('Название');
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, data.title);
const descriptionInputElem = screen.getByLabelText('Описание');
await userEvent.clear(descriptionInputElem);
await userEvent.type(descriptionInputElem, data.description);
const dateInputElem = screen.getByLabelText('Дата');
await userEvent.clear(dateInputElem);
await userEvent.type(dateInputElem, data.date);
const amountInputElem = screen.getByLabelText('Сумма');
await userEvent.clear(amountInputElem);
await userEvent.type(amountInputElem, data.amount);
const countInputElem = screen.getByLabelText('Количество');
await userEvent.clear(countInputElem);
await userEvent.type(countInputElem, data.count);
};
describe('TestEditExpenseItemButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Редактировать');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Сохранить');
await fillForm({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29',
amount: '12',
count: '1',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название элемента',
description: 'Описание элемента',
date: '2025-08-29T00:00:00.000Z',
amount: 12,
count: 1,
currency: 'RUB',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -1,19 +1,25 @@
import { Input } from '@/shared/ui/Input';
import { EditExpenseItemForm } from '@/entity/expenses/item';
import { Modal } from '@/shared/ui/Modal';
import { useAddExpenseItemButton } from '../hooks/useEditExpenseItemButton';
import classes from './EditExpenseItemButton.module.css';
import { useEditExpenseItemButton } from '../hooks/useEditExpenseItemButton';
import type { ExpenseListItem } from '@/entity/expenses/item';
import type { FC } from 'react';
import type { ButtonHTMLAttributes, FC, RefObject } from 'react';
type EditExpenseItemButtonProps = {
type BaseButtonProps = ButtonHTMLAttributes<HTMLButtonElement>;
type EditExpenseItemButtonProps = BaseButtonProps & {
item: ExpenseListItem;
ref?: RefObject<HTMLButtonElement>;
};
const EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
const { item } = props;
const {
ref,
item,
onClick,
...restProps
} = props;
const {
isOpen,
@ -21,58 +27,22 @@ const EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
onClickHandler,
isPending,
onFormSubmitHandler,
} = useAddExpenseItemButton({ item });
} = useEditExpenseItemButton({ item, onClick });
return (
<>
<button type="button" onClick={onClickHandler}>
<button
ref={ref}
disabled={isPending}
type="button"
onClick={onClickHandler}
{...restProps}
>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form className={classes.form} onSubmit={onFormSubmitHandler}>
<h2>Добавить список расходов</h2>
<Input
defaultValue={item.title}
disabled={isPending}
label="Название"
name="title"
/>
<Input
defaultValue={item.description ?? ''}
disabled={isPending}
label="Описание"
name="description"
/>
<Input
defaultValue={new Date(item.date).toISOString().split('T')[0]}
disabled={isPending}
label="Дата"
name="date"
type="date"
/>
<Input
defaultValue={item.amount}
disabled={isPending}
label="Сумма"
name="amount"
type="number"
/>
<Input
defaultValue={item.count ?? 1}
disabled={isPending}
label="Количество"
name="count"
type="number"
/>
<button disabled={isPending} type="submit">Сохранить</button>
</form>
<EditExpenseItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);

View File

@ -1,7 +1,9 @@
import { type FormEventHandler, type MouseEventHandler, useState } from 'react';
import { type MouseEventHandler, useState } from 'react';
import { useAddExpensesList } from '@/entity/expenses/list';
import type { AddExpenseListFormValues } from '@/entity/expenses/list';
type UseAddExpensesListButtonArgs = {
onClick?: MouseEventHandler<HTMLButtonElement>;
};
@ -21,16 +23,9 @@ export const useAddExpensesListButton = (args: UseAddExpensesListButtonArgs) =>
setIsOpen(true);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
await addExpensesListMutation({ title });
const onFormSubmitHandler = async (data: AddExpenseListFormValues) => {
await addExpensesListMutation(data);
setIsOpen(false);
}
};
return {

View File

@ -0,0 +1,104 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import AddExpensesListButton from './AddExpensesListButton.ui';
import type { AddExpenseListBody, AddExpenseListSuccessResponse } from '@/entity/expenses/list';
const renderButton = () => {
render(
<AddExpensesListButton data-testid="test-button" />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('test-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const fillForm = async (data: { title: string }) => {
await userEvent.type(screen.getByLabelText('Название'), data.title);
};
const submitHandler = (requestSpy: jest.Mock) => http.post<never, AddExpenseListBody, AddExpenseListSuccessResponse>(
'http://localhost:3000/expenses/list',
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json({
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
});
},
);
describe('Test AddExpensesListButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Добавить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = within(modalElem).getByText('Добавить');
await fillForm({
title: 'Название списка',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название списка',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -1,4 +1,4 @@
import { Input } from '@/shared/ui/Input';
import { AddExpensesListForm } from '@/entity/expenses/list';
import { Modal } from '@/shared/ui/Modal';
import { useAddExpensesListButton } from '../hooks/useAddExpensesListButton';
@ -27,13 +27,7 @@ const AddExpensesListButton: FC<AddExpensesListButtonProps> = (props) => {
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Добавить список расходов</h2>
<Input disabled={isPending} label="Название" name="title" />
<button disabled={isPending} type="submit">Добавить</button>
</form>
<AddExpensesListForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);

View File

@ -1,4 +1,4 @@
import { type FormEventHandler, useState } from 'react';
import { useState } from 'react';
import { type ExpenseList, useDeleteExpensesList } from '@/entity/expenses/list';
@ -20,8 +20,7 @@ export const useDeleteExpensesListButton = (args: UseDeleteExpensesListButtonArg
setIsOpen(false);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const onFormSubmitHandler = async () => {
await deleteExpensesListAsync({ id });
setIsOpen(false);
};

View File

@ -0,0 +1,74 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import DeleteExpensesListButton from './DeleteExpensesListButton.ui';
import type { DeleteExpenseListPath } from '@/entity/expenses/list';
const deletingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderButton = () => {
render(
<DeleteExpensesListButton data-testid="delete-expense-list-button" list={deletingList} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('delete-expense-list-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = http.delete<DeleteExpenseListPath>(`http://localhost:3000/expenses/list/${deletingList.id}`, () => HttpResponse.json(deletingList));
describe('Test DeleteExpensesListButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Удалить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(submitHandler);
const { modalElem } = await openModal();
const submitButtonElem = within(modalElem).getByText('Удалить');
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});

View File

@ -1,3 +1,4 @@
import { DeleteExpensesListForm } from '@/entity/expenses/list';
import { Modal } from '@/shared/ui/Modal';
import { useDeleteExpensesListButton } from '../hooks/useDeleteExpensesListButton';
@ -12,7 +13,7 @@ type DeleteExpensesListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
const DeleteExpensesListButton = (props: DeleteExpensesListButtonProps) => {
const { list, ...restProps } = props;
const { id, title } = list;
const { id } = list;
const {
isOpen,
isPending,
@ -28,21 +29,11 @@ const DeleteExpensesListButton = (props: DeleteExpensesListButtonProps) => {
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Удалить список расходов</h2>
<p>
Вы действительно хотите удалить список расходов
{' '}
{`"${title}"`}
{' '}
?
</p>
<button disabled={isPending} type="submit">Удалить</button>
</form>
<DeleteExpensesListForm
disabled={isPending}
list={list}
onSubmit={onFormSubmitHandler}
/>
</Modal>
</>
);

View File

@ -1,6 +1,8 @@
import { type FormEventHandler, useState } from 'react';
import { useState } from 'react';
import { type ExpenseList, useEditExpensesList } from '@/entity/expenses/list';
import { useEditExpensesList } from '@/entity/expenses/list';
import type { EditExpenseListFormValues, ExpenseList } from '@/entity/expenses/list';
type UseEditExpensesListButtonArgs = {
id: ExpenseList['id'];
@ -20,18 +22,12 @@ export const useEditExpensesListButton = (args: UseEditExpensesListButtonArgs) =
setIsOpen(false);
};
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
if (typeof title === 'string') {
const onFormSubmitHandler = async (data: EditExpenseListFormValues) => {
await editExpensesListAsync({
path: { id },
data: { title },
data,
});
setIsOpen(false);
}
};
return {

View File

@ -0,0 +1,116 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import EditExpensesListButton from './EditExpensesListButton.ui';
import type {
EditExpenseListBody,
EditExpenseListPath,
EditExpenseListSuccessResponse,
} from '@/entity/expenses/list';
const editingList = {
id: '10cc093c-3875-4d33-a40a-df526266e262',
title: 'Mock list',
items: [],
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderButton = () => {
render(
<EditExpensesListButton data-testid="edit-expense-list-button" list={editingList} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('edit-expense-list-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = (requestSpy: jest.Mock) => http.patch<
EditExpenseListPath,
EditExpenseListBody,
EditExpenseListSuccessResponse
>(
`http://localhost:3000/expenses/list/${editingList.id}`,
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json(editingList);
},
);
const fillForm = async (data: { title: string }) => {
const titleInputElem = screen.getByLabelText('Название');
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, data.title);
};
describe('Test EditExpensesListButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Редактировать');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = screen.getByText('Сохранить');
await fillForm({
title: 'Название списка',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
title: 'Название списка',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -1,4 +1,4 @@
import { Input } from '@/shared/ui/Input';
import { EditExpensesListForm } from '@/entity/expenses/list';
import { Modal } from '@/shared/ui/Modal';
import { useEditExpensesListButton } from '../hooks/useEditExpensesListButton';
@ -29,18 +29,7 @@ const EditExpensesListButton: FC<EditExpensesListButtonProps> = (props) => {
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<form onSubmit={onFormSubmitHandler}>
<h2>Редактировать список расходов</h2>
<Input
defaultValue={list.title}
disabled={isPending}
label="Название"
name="title"
/>
<button disabled={isPending} type="submit">Сохранить</button>
</form>
<EditExpensesListForm disabled={isPending} list={list} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);

View File

@ -16,7 +16,7 @@ const Modal: FC<ModalProps> = (props) => {
}
return (
<div className={classes.wrapper} onClick={onClose}>
<div className={classes.wrapper} role="dialog" onClick={onClose}>
<div className={classes.content} onClick={(e) => { e.stopPropagation(); }}>
<span className={classes.closeIcon} onClick={onClose}>&times;</span>