feat(frontend): add tests form expense list and items
This commit is contained in:
parent
9cb8c376dd
commit
06897a86db
@ -15,6 +15,9 @@ export default defineConfig({
|
|||||||
}),
|
}),
|
||||||
pluginTypeCheck(),
|
pluginTypeCheck(),
|
||||||
],
|
],
|
||||||
|
html: {
|
||||||
|
title: 'Investments Control',
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
|
|||||||
@ -2,4 +2,16 @@ export { useAddExpenseItem } from './hooks/useAddExpenseItem';
|
|||||||
export { useDeleteExpenseItem } from './hooks/useDeleteExpenseItem';
|
export { useDeleteExpenseItem } from './hooks/useDeleteExpenseItem';
|
||||||
export { useEditExpenseItem } from './hooks/useEditExpenseItem';
|
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';
|
export type { ExpenseListItem } from './types';
|
||||||
|
|||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -2,8 +2,9 @@ import { api } from '@/shared/api/client';
|
|||||||
|
|
||||||
import type { paths } from '@/shared/api/schema';
|
import type { paths } from '@/shared/api/schema';
|
||||||
|
|
||||||
type AddExpenseListBody = paths['/expenses/list']['post']['requestBody']['content']['application/json'];
|
export type AddExpenseListPath = paths['/expenses/list']['post']['parameters']['path'];
|
||||||
type AddExpenseListSuccessResponse = paths['/expenses/list']['post']['responses']['200']['content']['application/json'];
|
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) {
|
export async function addExpensesList(data: AddExpenseListBody) {
|
||||||
const { data: result } = await api.post<AddExpenseListSuccessResponse>('/expenses/list', data);
|
const { data: result } = await api.post<AddExpenseListSuccessResponse>('/expenses/list', data);
|
||||||
|
|||||||
@ -2,8 +2,9 @@ import { api } from '@/shared/api/client';
|
|||||||
|
|
||||||
import type { paths } from '@/shared/api/schema';
|
import type { paths } from '@/shared/api/schema';
|
||||||
|
|
||||||
type DeleteExpenseListPath = paths['/expenses/list/{id}']['delete']['parameters']['path'];
|
export type DeleteExpenseListPath = paths['/expenses/list/{id}']['delete']['parameters']['path'];
|
||||||
type DeleteExpenseSuccessResponse = paths['/expenses/list/{id}']['delete']['responses']['200']['content']['application/json'];
|
export type DeleteExpenseSuccessResponse = paths['/expenses/list/{id}']['delete']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
export async function deleteExpensesList(data: DeleteExpenseListPath) {
|
export async function deleteExpensesList(data: DeleteExpenseListPath) {
|
||||||
const { id } = data;
|
const { id } = data;
|
||||||
const { data: result } = await api.delete<DeleteExpenseSuccessResponse>(`/expenses/list/${id}`);
|
const { data: result } = await api.delete<DeleteExpenseSuccessResponse>(`/expenses/list/${id}`);
|
||||||
|
|||||||
@ -2,9 +2,9 @@ import { api } from '@/shared/api/client';
|
|||||||
|
|
||||||
import type { paths } from '@/shared/api/schema';
|
import type { paths } from '@/shared/api/schema';
|
||||||
|
|
||||||
type EditExpenseListPath = paths['/expenses/list/{id}']['patch']['parameters']['path'];
|
export type EditExpenseListPath = paths['/expenses/list/{id}']['patch']['parameters']['path'];
|
||||||
type EditExpenseListBody = paths['/expenses/list/{id}']['patch']['requestBody']['content']['application/json'];
|
export 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 EditExpenseListSuccessResponse = paths['/expenses/list/{id}']['patch']['responses']['200']['content']['application/json'];
|
||||||
|
|
||||||
export async function editExpensesList({ data, path }: {
|
export async function editExpensesList({ data, path }: {
|
||||||
path: EditExpenseListPath;
|
path: EditExpenseListPath;
|
||||||
|
|||||||
@ -5,3 +5,15 @@ export { useExpensesList } from './hooks/useExpensesList';
|
|||||||
export { useEditExpensesList } from './hooks/useEditExpensesList';
|
export { useEditExpensesList } from './hooks/useEditExpensesList';
|
||||||
|
|
||||||
export { useExpenseList } from './hooks/useExpenseList';
|
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';
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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: 'Название списка',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -0,0 +1,5 @@
|
|||||||
|
.form {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
@ -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: 'Название списка',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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;
|
||||||
@ -1,42 +1,32 @@
|
|||||||
import { type FormEventHandler, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { useAddExpenseItem } from '@/entity/expenses/item';
|
import { useAddExpenseItem } from '@/entity/expenses/item';
|
||||||
|
|
||||||
|
import type { AddExpenseItemFormValues } from '@/entity/expenses/item';
|
||||||
import type { ExpenseList } from '@/entity/expenses/list';
|
import type { ExpenseList } from '@/entity/expenses/list';
|
||||||
|
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
|
||||||
|
|
||||||
type UseAddExpenseItemButtonArgs = {
|
type UseAddExpenseItemButtonArgs = {
|
||||||
listId: ExpenseList['id'];
|
listId: ExpenseList['id'];
|
||||||
|
onClick?: ButtonHTMLAttributes<HTMLButtonElement>['onClick'];
|
||||||
};
|
};
|
||||||
export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
|
export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
|
||||||
const { listId } = args;
|
const { listId, onClick } = args;
|
||||||
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync: addExpenseItemMutation, isPending } = useAddExpenseItem({ id: listId });
|
const { mutateAsync: addExpenseItemMutation, isPending } = useAddExpenseItem({ id: listId });
|
||||||
const onCloseModalHandler = () => setIsOpen(false);
|
const onCloseModalHandler = () => setIsOpen(false);
|
||||||
|
|
||||||
const onClickHandler = () => setIsOpen(true);
|
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
|
const onFormSubmitHandler = async (data: AddExpenseItemFormValues) => {
|
||||||
event.preventDefault();
|
// 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);
|
setIsOpen(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,19 +1,25 @@
|
|||||||
import { Input } from '@/shared/ui/Input';
|
import { AddExpenseItemForm } from '@/entity/expenses/item';
|
||||||
import { Modal } from '@/shared/ui/Modal';
|
import { Modal } from '@/shared/ui/Modal';
|
||||||
|
|
||||||
import { useAddExpenseItemButton } from '../hooks/useAddExpenseItemButton';
|
import { useAddExpenseItemButton } from '../hooks/useAddExpenseItemButton';
|
||||||
|
|
||||||
import classes from './AddExpenseItemButton.module.css';
|
|
||||||
|
|
||||||
import type { ExpenseList } from '@/entity/expenses/list';
|
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'];
|
listId: ExpenseList['id'];
|
||||||
|
ref?: RefObject<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
|
const AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
|
||||||
const { listId } = props;
|
const {
|
||||||
|
listId,
|
||||||
|
ref,
|
||||||
|
onClick,
|
||||||
|
...buttonProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -21,56 +27,21 @@ const AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
|
|||||||
onClickHandler,
|
onClickHandler,
|
||||||
onCloseModalHandler,
|
onCloseModalHandler,
|
||||||
onFormSubmitHandler,
|
onFormSubmitHandler,
|
||||||
} = useAddExpenseItemButton({ listId });
|
} = useAddExpenseItemButton({ listId, onClick });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={onClickHandler}>
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={onClickHandler}
|
||||||
|
{...buttonProps}
|
||||||
|
>
|
||||||
Добавить элемент
|
Добавить элемент
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
||||||
<form className={classes.form} onSubmit={onFormSubmitHandler}>
|
<AddExpenseItemForm disabled={isPending} 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>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
|
|
||||||
|
|||||||
@ -1,26 +1,30 @@
|
|||||||
import { type FormEventHandler, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { type ExpenseListItem, useDeleteExpenseItem } from '@/entity/expenses/item';
|
import { type ExpenseListItem, useDeleteExpenseItem } from '@/entity/expenses/item';
|
||||||
|
|
||||||
|
import type { MouseEventHandler } from 'react';
|
||||||
|
|
||||||
type UseDeleteExpenseItemButtonArgs = {
|
type UseDeleteExpenseItemButtonArgs = {
|
||||||
item: ExpenseListItem;
|
item: ExpenseListItem;
|
||||||
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useDeleteExpenseItemButton = (args: UseDeleteExpenseItemButtonArgs) => {
|
export const useDeleteExpenseItemButton = (args: UseDeleteExpenseItemButtonArgs) => {
|
||||||
const { item } = args;
|
const { item, onClick } = args;
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
const onCloseModalHandler = () => setIsOpen(false);
|
const onCloseModalHandler = () => setIsOpen(false);
|
||||||
const onClickHandler = () => setIsOpen(true);
|
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
const { mutateAsync: deleteExpenseItemMutation, isPending } = useDeleteExpenseItem({
|
const { mutateAsync: deleteExpenseItemMutation, isPending } = useDeleteExpenseItem({
|
||||||
listId: item.expenseListId,
|
listId: item.expenseListId,
|
||||||
id: item.id,
|
id: item.id,
|
||||||
});
|
});
|
||||||
|
|
||||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
|
const onFormSubmitHandler = async () => {
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
await deleteExpenseItemMutation();
|
await deleteExpenseItemMutation();
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,35 +1,45 @@
|
|||||||
|
import { DeleteExpenseItemForm } from '@/entity/expenses/item';
|
||||||
import { Modal } from '@/shared/ui/Modal';
|
import { Modal } from '@/shared/ui/Modal';
|
||||||
|
|
||||||
import { useDeleteExpenseItemButton } from '../hooks/useDeleteExpenseItemButton';
|
import { useDeleteExpenseItemButton } from '../hooks/useDeleteExpenseItemButton';
|
||||||
|
|
||||||
import type { ExpenseListItem } from '@/entity/expenses/item';
|
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;
|
item: ExpenseListItem;
|
||||||
|
ref?: RefObject<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
const DeleteExpenseItemButton: FC<DeleteExpenseItemButtonProps> = (props) => {
|
const DeleteExpenseItemButton: FC<DeleteExpenseItemButtonProps> = (props) => {
|
||||||
const { item } = props;
|
const {
|
||||||
|
item,
|
||||||
|
ref,
|
||||||
|
onClick,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
onCloseModalHandler,
|
onCloseModalHandler,
|
||||||
onClickHandler,
|
onClickHandler,
|
||||||
isPending,
|
isPending,
|
||||||
onFormSubmitHandler,
|
onFormSubmitHandler,
|
||||||
} = useDeleteExpenseItemButton({ item });
|
} = useDeleteExpenseItemButton({ item, onClick });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={onClickHandler}>
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
onClick={onClickHandler}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
Удалить
|
Удалить
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
||||||
<form onSubmit={onFormSubmitHandler}>
|
<DeleteExpenseItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
|
||||||
<h2>Удалить элемент ?</h2>
|
|
||||||
|
|
||||||
<button disabled={isPending} type="submit">Удалить</button>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 = {
|
type UseAddExpenseItemButtonArgs = {
|
||||||
item: ExpenseListItem;
|
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 [isOpen, setIsOpen] = useState(false);
|
||||||
const { mutateAsync: editExpenseItemMutation, isPending } = useEditExpenseItem({
|
const { mutateAsync: editExpenseItemMutation, isPending } = useEditExpenseItem({
|
||||||
@ -15,29 +19,15 @@ export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
|
|||||||
});
|
});
|
||||||
const onCloseModalHandler = () => setIsOpen(false);
|
const onCloseModalHandler = () => setIsOpen(false);
|
||||||
|
|
||||||
const onClickHandler = () => setIsOpen(true);
|
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
|
||||||
|
setIsOpen(true);
|
||||||
|
onClick?.(event);
|
||||||
|
};
|
||||||
|
|
||||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
|
const onFormSubmitHandler = async (data: EditExpenseItemFormValues) => {
|
||||||
event.preventDefault();
|
// todo добавить обработку ошибок (код + тест)
|
||||||
|
await editExpenseItemMutation(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 editExpenseItemMutation({
|
|
||||||
title,
|
|
||||||
date: new Date(date).toISOString(),
|
|
||||||
amount: Number(amount),
|
|
||||||
description,
|
|
||||||
currency: 'RUB',
|
|
||||||
count: Number(count),
|
|
||||||
});
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,19 +1,25 @@
|
|||||||
import { Input } from '@/shared/ui/Input';
|
import { EditExpenseItemForm } from '@/entity/expenses/item';
|
||||||
import { Modal } from '@/shared/ui/Modal';
|
import { Modal } from '@/shared/ui/Modal';
|
||||||
|
|
||||||
import { useAddExpenseItemButton } from '../hooks/useEditExpenseItemButton';
|
import { useEditExpenseItemButton } from '../hooks/useEditExpenseItemButton';
|
||||||
|
|
||||||
import classes from './EditExpenseItemButton.module.css';
|
|
||||||
|
|
||||||
import type { ExpenseListItem } from '@/entity/expenses/item';
|
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;
|
item: ExpenseListItem;
|
||||||
|
ref?: RefObject<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
|
const EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
|
||||||
const { item } = props;
|
const {
|
||||||
|
ref,
|
||||||
|
item,
|
||||||
|
onClick,
|
||||||
|
...restProps
|
||||||
|
} = props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
@ -21,58 +27,22 @@ const EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
|
|||||||
onClickHandler,
|
onClickHandler,
|
||||||
isPending,
|
isPending,
|
||||||
onFormSubmitHandler,
|
onFormSubmitHandler,
|
||||||
} = useAddExpenseItemButton({ item });
|
} = useEditExpenseItemButton({ item, onClick });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<button type="button" onClick={onClickHandler}>
|
<button
|
||||||
|
ref={ref}
|
||||||
|
disabled={isPending}
|
||||||
|
type="button"
|
||||||
|
onClick={onClickHandler}
|
||||||
|
{...restProps}
|
||||||
|
>
|
||||||
Редактировать
|
Редактировать
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
||||||
<form className={classes.form} onSubmit={onFormSubmitHandler}>
|
<EditExpenseItemForm disabled={isPending} item={item} 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>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 { useAddExpensesList } from '@/entity/expenses/list';
|
||||||
|
|
||||||
|
import type { AddExpenseListFormValues } from '@/entity/expenses/list';
|
||||||
|
|
||||||
type UseAddExpensesListButtonArgs = {
|
type UseAddExpensesListButtonArgs = {
|
||||||
onClick?: MouseEventHandler<HTMLButtonElement>;
|
onClick?: MouseEventHandler<HTMLButtonElement>;
|
||||||
};
|
};
|
||||||
@ -21,16 +23,9 @@ export const useAddExpensesListButton = (args: UseAddExpensesListButtonArgs) =>
|
|||||||
setIsOpen(true);
|
setIsOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
|
const onFormSubmitHandler = async (data: AddExpenseListFormValues) => {
|
||||||
event.preventDefault();
|
await addExpensesListMutation(data);
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
|
||||||
const title = formData.get('title');
|
|
||||||
|
|
||||||
if (typeof title === 'string') {
|
|
||||||
await addExpensesListMutation({ title });
|
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Input } from '@/shared/ui/Input';
|
import { AddExpensesListForm } from '@/entity/expenses/list';
|
||||||
import { Modal } from '@/shared/ui/Modal';
|
import { Modal } from '@/shared/ui/Modal';
|
||||||
|
|
||||||
import { useAddExpensesListButton } from '../hooks/useAddExpensesListButton';
|
import { useAddExpensesListButton } from '../hooks/useAddExpensesListButton';
|
||||||
@ -27,13 +27,7 @@ const AddExpensesListButton: FC<AddExpensesListButtonProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
||||||
<form onSubmit={onFormSubmitHandler}>
|
<AddExpensesListForm disabled={isPending} onSubmit={onFormSubmitHandler} />
|
||||||
<h2>Добавить список расходов</h2>
|
|
||||||
|
|
||||||
<Input disabled={isPending} label="Название" name="title" />
|
|
||||||
|
|
||||||
<button disabled={isPending} type="submit">Добавить</button>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { type FormEventHandler, useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { type ExpenseList, useDeleteExpensesList } from '@/entity/expenses/list';
|
import { type ExpenseList, useDeleteExpensesList } from '@/entity/expenses/list';
|
||||||
|
|
||||||
@ -20,8 +20,7 @@ export const useDeleteExpensesListButton = (args: UseDeleteExpensesListButtonArg
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
|
const onFormSubmitHandler = async () => {
|
||||||
event.preventDefault();
|
|
||||||
await deleteExpensesListAsync({ id });
|
await deleteExpensesListAsync({ id });
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,3 +1,4 @@
|
|||||||
|
import { DeleteExpensesListForm } from '@/entity/expenses/list';
|
||||||
import { Modal } from '@/shared/ui/Modal';
|
import { Modal } from '@/shared/ui/Modal';
|
||||||
|
|
||||||
import { useDeleteExpensesListButton } from '../hooks/useDeleteExpensesListButton';
|
import { useDeleteExpensesListButton } from '../hooks/useDeleteExpensesListButton';
|
||||||
@ -12,7 +13,7 @@ type DeleteExpensesListButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
|||||||
|
|
||||||
const DeleteExpensesListButton = (props: DeleteExpensesListButtonProps) => {
|
const DeleteExpensesListButton = (props: DeleteExpensesListButtonProps) => {
|
||||||
const { list, ...restProps } = props;
|
const { list, ...restProps } = props;
|
||||||
const { id, title } = list;
|
const { id } = list;
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
isPending,
|
isPending,
|
||||||
@ -28,21 +29,11 @@ const DeleteExpensesListButton = (props: DeleteExpensesListButtonProps) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
||||||
<form onSubmit={onFormSubmitHandler}>
|
<DeleteExpensesListForm
|
||||||
<h2>Удалить список расходов</h2>
|
disabled={isPending}
|
||||||
|
list={list}
|
||||||
<p>
|
onSubmit={onFormSubmitHandler}
|
||||||
Вы действительно хотите удалить список расходов
|
/>
|
||||||
{' '}
|
|
||||||
|
|
||||||
{`"${title}"`}
|
|
||||||
|
|
||||||
{' '}
|
|
||||||
?
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<button disabled={isPending} type="submit">Удалить</button>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -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 = {
|
type UseEditExpensesListButtonArgs = {
|
||||||
id: ExpenseList['id'];
|
id: ExpenseList['id'];
|
||||||
@ -20,18 +22,12 @@ export const useEditExpensesListButton = (args: UseEditExpensesListButtonArgs) =
|
|||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
|
const onFormSubmitHandler = async (data: EditExpenseListFormValues) => {
|
||||||
event.preventDefault();
|
|
||||||
const formData = new FormData(event.currentTarget);
|
|
||||||
const title = formData.get('title');
|
|
||||||
|
|
||||||
if (typeof title === 'string') {
|
|
||||||
await editExpensesListAsync({
|
await editExpensesListAsync({
|
||||||
path: { id },
|
path: { id },
|
||||||
data: { title },
|
data,
|
||||||
});
|
});
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { Input } from '@/shared/ui/Input';
|
import { EditExpensesListForm } from '@/entity/expenses/list';
|
||||||
import { Modal } from '@/shared/ui/Modal';
|
import { Modal } from '@/shared/ui/Modal';
|
||||||
|
|
||||||
import { useEditExpensesListButton } from '../hooks/useEditExpensesListButton';
|
import { useEditExpensesListButton } from '../hooks/useEditExpensesListButton';
|
||||||
@ -29,18 +29,7 @@ const EditExpensesListButton: FC<EditExpensesListButtonProps> = (props) => {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
<Modal open={isOpen} onClose={onCloseModalHandler}>
|
||||||
<form onSubmit={onFormSubmitHandler}>
|
<EditExpensesListForm disabled={isPending} list={list} onSubmit={onFormSubmitHandler} />
|
||||||
<h2>Редактировать список расходов</h2>
|
|
||||||
|
|
||||||
<Input
|
|
||||||
defaultValue={list.title}
|
|
||||||
disabled={isPending}
|
|
||||||
label="Название"
|
|
||||||
name="title"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<button disabled={isPending} type="submit">Сохранить</button>
|
|
||||||
</form>
|
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -16,7 +16,7 @@ const Modal: FC<ModalProps> = (props) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classes.wrapper} onClick={onClose}>
|
<div className={classes.wrapper} role="dialog" onClick={onClose}>
|
||||||
<div className={classes.content} onClick={(e) => { e.stopPropagation(); }}>
|
<div className={classes.content} onClick={(e) => { e.stopPropagation(); }}>
|
||||||
<span className={classes.closeIcon} onClick={onClose}>×</span>
|
<span className={classes.closeIcon} onClick={onClose}>×</span>
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user