From 06897a86db2b1ae947e4bf7cf406ce945e6a4dea Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 3 Sep 2025 06:22:06 +0300 Subject: [PATCH] feat(frontend): add tests form expense list and items --- frontend/rsbuild.config.ts | 3 + frontend/src/entity/expenses/item/index.ts | 12 ++ .../AddExpenseItemForm.module.css} | 0 .../AddExpenseItemForm.test.tsx | 76 +++++++++ .../AddExpenseItemForm.ui.tsx | 110 +++++++++++++ .../DeleteExpenseItemForm.test.tsx | 60 +++++++ .../DeleteExpenseItemForm.ui.tsx | 40 +++++ .../EditExpenseItemForm.module.css} | 0 .../EditExpenseItemForm.test.tsx | 120 ++++++++++++++ .../EditExpenseItemForm.ui.tsx | 113 ++++++++++++++ .../expenses/list/api/addExpensesList.ts | 5 +- .../expenses/list/api/deleteExpensesList.ts | 5 +- .../expenses/list/api/editExpensesList.ts | 6 +- frontend/src/entity/expenses/list/index.ts | 12 ++ .../ui/AddExpenseListForm.module.css | 5 + .../ui/AddExpensesListForm.test.tsx | 54 +++++++ .../ui/AddExpensesListForm.ui.tsx | 62 ++++++++ .../ui/DeleteExpensesListForm.test.tsx | 55 +++++++ .../ui/DeleteExpensesListForm.ui.tsx | 64 ++++++++ .../ui/EditExpenseListForm.module.css | 5 + .../ui/EditExpensesListForm.test.tsx | 78 ++++++++++ .../ui/EditExpensesListForm.ui.tsx | 68 ++++++++ .../hooks/useAddExpenseItemButton.ts | 36 ++--- .../ui/AddExpenseItemButton.test.tsx | 126 +++++++++++++++ .../ui/AddExpenseItemButton.ui.tsx | 69 +++------ .../hooks/useDeleteExpenseItemButton.ts | 20 ++- .../ui/DeleteExpenseItemButton.test.tsx | 79 ++++++++++ .../ui/DeleteExpenseItemButton.ui.tsx | 30 ++-- .../hooks/useEditExpenseItemButton.ts | 42 ++--- .../ui/EditExpenseItemButton.test.tsx | 146 ++++++++++++++++++ .../ui/EditExpenseItemButton.ui.tsx | 74 +++------ .../hooks/useAddExpensesListButton.ts | 17 +- .../ui/AddExpensesListButton.test.tsx | 104 +++++++++++++ .../ui/AddExpensesListButton.ui.tsx | 10 +- .../hooks/useDeleteExpensesListButton.ts | 5 +- .../ui/DeleteExpensesListButton.test.tsx | 74 +++++++++ .../ui/DeleteExpensesListButton.ui.tsx | 23 +-- .../hooks/useEditExpensesListButton.ts | 24 ++- .../ui/EditExpensesListButton.test.tsx | 116 ++++++++++++++ .../ui/EditExpensesListButton.ui.tsx | 15 +- frontend/src/shared/ui/Modal/ui/Modal.ui.tsx | 2 +- 41 files changed, 1724 insertions(+), 241 deletions(-) rename frontend/src/{features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.module.css => entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.module.css} (100%) create mode 100644 frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx create mode 100644 frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx create mode 100644 frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.test.tsx create mode 100644 frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui.tsx rename frontend/src/{features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.module.css => entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.module.css} (100%) create mode 100644 frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.test.tsx create mode 100644 frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx create mode 100644 frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpenseListForm.module.css create mode 100644 frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.test.tsx create mode 100644 frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.ui.tsx create mode 100644 frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.test.tsx create mode 100644 frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.ui.tsx create mode 100644 frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpenseListForm.module.css create mode 100644 frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.test.tsx create mode 100644 frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.ui.tsx create mode 100644 frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.test.tsx create mode 100644 frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.test.tsx create mode 100644 frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.test.tsx create mode 100644 frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.test.tsx create mode 100644 frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.test.tsx create mode 100644 frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.test.tsx diff --git a/frontend/rsbuild.config.ts b/frontend/rsbuild.config.ts index 0685c5a..db268c2 100644 --- a/frontend/rsbuild.config.ts +++ b/frontend/rsbuild.config.ts @@ -15,6 +15,9 @@ export default defineConfig({ }), pluginTypeCheck(), ], + html: { + title: 'Investments Control', + }, server: { proxy: { '/api': { diff --git a/frontend/src/entity/expenses/item/index.ts b/frontend/src/entity/expenses/item/index.ts index 21d2e68..06ce7e2 100644 --- a/frontend/src/entity/expenses/item/index.ts +++ b/frontend/src/entity/expenses/item/index.ts @@ -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'; diff --git a/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.module.css b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.module.css similarity index 100% rename from frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.module.css rename to frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.module.css diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx new file mode 100644 index 0000000..f984945 --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render(); + + 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', + }); + }); +}); diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx new file mode 100644 index 0000000..cd0b2f5 --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/AddExpenseItemForm.ui.tsx @@ -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, 'onSubmit'>; + +export type AddExpenseItemFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (data: AddExpenseItemFormValues) => Promise | void; + ref?: RefObject; +}; + +const AddExpenseItemForm: FC = (props) => { + const { + disabled, + onSubmit, + ref, + ...formProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (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 ( +
+

Добавить список расходов

+ + + + + + + + + + + + +
+ ); +}; + +export default AddExpenseItemForm; diff --git a/frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.test.tsx b/frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.test.tsx new file mode 100644 index 0000000..38650b1 --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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); + }); +}); diff --git a/frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui.tsx new file mode 100644 index 0000000..e392269 --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui.tsx @@ -0,0 +1,40 @@ +import type { ExpenseListItem } from '../../types'; +import type { + FC, + FormEventHandler, + FormHTMLAttributes, + RefObject, +} from 'react'; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type DeleteExpenseItemFormProps = BaseFormProps & { + item: ExpenseListItem; + onSubmit?: (item: ExpenseListItem) => Promise | void; + disabled?: boolean; + ref?: RefObject; +}; + +const DeleteExpenseItemForm: FC = (props) => { + const { + item, + onSubmit, + ref, + disabled, + ...restProps + } = props; + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + onSubmit?.(item); + }; + + return ( +
+

Удалить элемент ?

+ + +
+ ); +}; + +export default DeleteExpenseItemForm; diff --git a/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.module.css b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.module.css similarity index 100% rename from frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.module.css rename to frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.module.css diff --git a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.test.tsx b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.test.tsx new file mode 100644 index 0000000..2b45485 --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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', + }); + }); +}); diff --git a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx new file mode 100644 index 0000000..7dc32e2 --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx @@ -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, 'onSubmit'>; +export type EditExpenseItemFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (data: EditExpenseItemFormValues) => Promise | void; + ref?: RefObject; + item: ExpenseListItem; +}; + +const EditExpenseItemForm: FC = (props) => { + const { + disabled, + onSubmit, + ref, + item, + ...formProps + } = props; + + const onFormSubmitHandler: FormEventHandler = 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 ( +
+

Добавить список расходов

+ + + + + + + + + + + + +
+ ); +}; + +export default EditExpenseItemForm; diff --git a/frontend/src/entity/expenses/list/api/addExpensesList.ts b/frontend/src/entity/expenses/list/api/addExpensesList.ts index 4c22573..609963c 100644 --- a/frontend/src/entity/expenses/list/api/addExpensesList.ts +++ b/frontend/src/entity/expenses/list/api/addExpensesList.ts @@ -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('/expenses/list', data); diff --git a/frontend/src/entity/expenses/list/api/deleteExpensesList.ts b/frontend/src/entity/expenses/list/api/deleteExpensesList.ts index ad67ae0..b19ac59 100644 --- a/frontend/src/entity/expenses/list/api/deleteExpensesList.ts +++ b/frontend/src/entity/expenses/list/api/deleteExpensesList.ts @@ -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(`/expenses/list/${id}`); diff --git a/frontend/src/entity/expenses/list/api/editExpensesList.ts b/frontend/src/entity/expenses/list/api/editExpensesList.ts index 1004f33..1dc8af5 100644 --- a/frontend/src/entity/expenses/list/api/editExpensesList.ts +++ b/frontend/src/entity/expenses/list/api/editExpensesList.ts @@ -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; diff --git a/frontend/src/entity/expenses/list/index.ts b/frontend/src/entity/expenses/list/index.ts index 31cedc5..5338115 100644 --- a/frontend/src/entity/expenses/list/index.ts +++ b/frontend/src/entity/expenses/list/index.ts @@ -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'; diff --git a/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpenseListForm.module.css b/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpenseListForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpenseListForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.test.tsx b/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.test.tsx new file mode 100644 index 0000000..d12400c --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render(); + + 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: 'Название списка', + }); + }); +}); diff --git a/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.ui.tsx b/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.ui.tsx new file mode 100644 index 0000000..c8098f1 --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/AddExpensesListForm/ui/AddExpensesListForm.ui.tsx @@ -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, 'onSubmit'>; + +export type AddExpenseListFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit: (data: AddExpenseListFormValues) => Promise | void; + ref?: RefObject; +}; + +const AddExpensesListForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + + const formData = new FormData(event.currentTarget); + + const title = formData.get('title'); + + if (typeof title === 'string') { + onSubmit?.({ + title, + }); + } + }; + + return ( +
+

Добавить список расходов

+ + + + +
+ ); +}; + +export default AddExpensesListForm; diff --git a/frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.test.tsx b/frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.test.tsx new file mode 100644 index 0000000..d62fb04 --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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); + }); +}); diff --git a/frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.ui.tsx b/frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.ui.tsx new file mode 100644 index 0000000..1f67993 --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/DeleteExpensesListForm/ui/DeleteExpensesListForm.ui.tsx @@ -0,0 +1,64 @@ +import type { ExpenseList } from '@/entity/expenses/list'; +import type { + FC, FormEventHandler, FormHTMLAttributes, RefObject, +} from 'react'; + +type BaseFormProps = Omit, 'onSubmit'>; + +export type DeleteExpenseListFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit?: (list: ExpenseList) => Promise | void; + ref?: RefObject; + list: ExpenseList; +}; + +const DeleteExpensesListForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + list, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + onSubmit?.(list); + }; + + return ( +
+

Удалить список расходов

+ +

+ Вы действительно хотите удалить список расходов + {' '} + + {`"${list.title}"`} + + {' '} + ? +

+ + { + list?.items && list?.items?.length > 0 + ? ( +

+ В нем содержится + {' '} + + {list.items?.length} + + {' '} + расходов +

+ ) + : null + } + + +
+ ); +}; + +export default DeleteExpensesListForm; diff --git a/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpenseListForm.module.css b/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpenseListForm.module.css new file mode 100644 index 0000000..a97ffec --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpenseListForm.module.css @@ -0,0 +1,5 @@ +.form { + display: flex; + flex-direction: column; + gap: 10px; +} diff --git a/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.test.tsx b/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.test.tsx new file mode 100644 index 0000000..59dfb50 --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.test.tsx @@ -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) => { + const onSubmit = jest.fn(); + + render( + , + ); + + 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: 'Название списка', + }); + }); +}); diff --git a/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.ui.tsx b/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.ui.tsx new file mode 100644 index 0000000..dc8ba79 --- /dev/null +++ b/frontend/src/entity/expenses/list/ui/EditExpensesListForm/ui/EditExpensesListForm.ui.tsx @@ -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, 'onSubmit'>; + +export type EditExpensesListFormProps = BaseFormProps & { + disabled?: boolean; + onSubmit: (data: EditExpenseListFormValues) => Promise | void; + ref?: RefObject; + list: ExpenseList; +}; + +const EditExpensesListForm: FC = (props) => { + const { + ref, + disabled, + onSubmit, + list, + ...restProps + } = props; + + const onFormSubmitHandler: FormEventHandler = (event) => { + event.preventDefault(); + const formData = new FormData(event.currentTarget); + const title = formData.get('title'); + + if (typeof title === 'string') { + onSubmit({ + title, + }); + } + }; + + return ( +
+

Редактировать список расходов

+ + + + +
+ ); +}; + +export default EditExpensesListForm; diff --git a/frontend/src/features/expenses/items/AddExpenseItemButton/hooks/useAddExpenseItemButton.ts b/frontend/src/features/expenses/items/AddExpenseItemButton/hooks/useAddExpenseItemButton.ts index e29c7be..f778ce9 100644 --- a/frontend/src/features/expenses/items/AddExpenseItemButton/hooks/useAddExpenseItemButton.ts +++ b/frontend/src/features/expenses/items/AddExpenseItemButton/hooks/useAddExpenseItemButton.ts @@ -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['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 = (event) => { + setIsOpen(true); + onClick?.(event); + }; - const onFormSubmitHandler: FormEventHandler = 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); - } + setIsOpen(false); }; return { diff --git a/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.test.tsx b/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.test.tsx new file mode 100644 index 0000000..c1a16ec --- /dev/null +++ b/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.test.tsx @@ -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( + , + { 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(); + }); + }); +}); diff --git a/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.ui.tsx b/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.ui.tsx index 10fd29e..c36c87c 100644 --- a/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.ui.tsx +++ b/frontend/src/features/expenses/items/AddExpenseItemButton/ui/AddExpenseItemButton.ui.tsx @@ -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; + +type AddExpenseItemButtonProps = BaseButtonProps & { listId: ExpenseList['id']; + ref?: RefObject; }; const AddExpenseItemButton: FC = (props) => { - const { listId } = props; + const { + listId, + ref, + onClick, + ...buttonProps + } = props; const { isOpen, @@ -21,56 +27,21 @@ const AddExpenseItemButton: FC = (props) => { onClickHandler, onCloseModalHandler, onFormSubmitHandler, - } = useAddExpenseItemButton({ listId }); + } = useAddExpenseItemButton({ listId, onClick }); return ( <> - -
-

Добавить список расходов

- - - - - - - - - - - - -
+
diff --git a/frontend/src/features/expenses/items/DeleteExpenseItemButton/hooks/useDeleteExpenseItemButton.ts b/frontend/src/features/expenses/items/DeleteExpenseItemButton/hooks/useDeleteExpenseItemButton.ts index 205551e..e2b07c3 100644 --- a/frontend/src/features/expenses/items/DeleteExpenseItemButton/hooks/useDeleteExpenseItemButton.ts +++ b/frontend/src/features/expenses/items/DeleteExpenseItemButton/hooks/useDeleteExpenseItemButton.ts @@ -1,28 +1,32 @@ -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; }; 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 = (event) => { + setIsOpen(true); + onClick?.(event); + }; const { mutateAsync: deleteExpenseItemMutation, isPending } = useDeleteExpenseItem({ listId: item.expenseListId, id: item.id, }); - const onFormSubmitHandler: FormEventHandler = async (event) => { - event.preventDefault(); - - await deleteExpenseItemMutation(); - setIsOpen(false); + const onFormSubmitHandler = async () => { + await deleteExpenseItemMutation(); + setIsOpen(false); }; return { diff --git a/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.test.tsx b/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.test.tsx new file mode 100644 index 0000000..1bc8f3e --- /dev/null +++ b/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.test.tsx @@ -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( + , + { 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(`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(); + }); +}); diff --git a/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.ui.tsx b/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.ui.tsx index f01da98..3421aa7 100644 --- a/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.ui.tsx +++ b/frontend/src/features/expenses/items/DeleteExpenseItemButton/ui/DeleteExpenseItemButton.ui.tsx @@ -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; + +type DeleteExpenseItemButtonProps = BaseButtonProps & { item: ExpenseListItem; + ref?: RefObject; }; const DeleteExpenseItemButton: FC = (props) => { - const { item } = props; + const { + item, + ref, + onClick, + ...restProps + } = props; const { isOpen, onCloseModalHandler, onClickHandler, isPending, onFormSubmitHandler, - } = useDeleteExpenseItemButton({ item }); + } = useDeleteExpenseItemButton({ item, onClick }); return ( <> - -
-

Удалить элемент ?

- - -
+
); diff --git a/frontend/src/features/expenses/items/EditExpenseItemButton/hooks/useEditExpenseItemButton.ts b/frontend/src/features/expenses/items/EditExpenseItemButton/hooks/useEditExpenseItemButton.ts index 438a8b5..b97c2bd 100644 --- a/frontend/src/features/expenses/items/EditExpenseItemButton/hooks/useEditExpenseItemButton.ts +++ b/frontend/src/features/expenses/items/EditExpenseItemButton/hooks/useEditExpenseItemButton.ts @@ -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; }; -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 = (event) => { + setIsOpen(true); + onClick?.(event); + }; - const onFormSubmitHandler: FormEventHandler = 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), - }); - setIsOpen(false); - } + const onFormSubmitHandler = async (data: EditExpenseItemFormValues) => { + // todo добавить обработку ошибок (код + тест) + await editExpenseItemMutation(data); + setIsOpen(false); }; return { diff --git a/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.test.tsx b/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.test.tsx new file mode 100644 index 0000000..74ddd5b --- /dev/null +++ b/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.test.tsx @@ -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( + , + { 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(); + }); + }); +}); diff --git a/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.ui.tsx b/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.ui.tsx index d9788ad..c57b9f2 100644 --- a/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.ui.tsx +++ b/frontend/src/features/expenses/items/EditExpenseItemButton/ui/EditExpenseItemButton.ui.tsx @@ -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; + +type EditExpenseItemButtonProps = BaseButtonProps & { item: ExpenseListItem; + ref?: RefObject; }; const EditExpenseItemButton: FC = (props) => { - const { item } = props; + const { + ref, + item, + onClick, + ...restProps + } = props; const { isOpen, @@ -21,58 +27,22 @@ const EditExpenseItemButton: FC = (props) => { onClickHandler, isPending, onFormSubmitHandler, - } = useAddExpenseItemButton({ item }); + } = useEditExpenseItemButton({ item, onClick }); return ( <> - -
-

Добавить список расходов

- - - - - - - - - - - - -
+
); diff --git a/frontend/src/features/expenses/list/AddExpensesListButton/hooks/useAddExpensesListButton.ts b/frontend/src/features/expenses/list/AddExpensesListButton/hooks/useAddExpensesListButton.ts index 30845ea..77a9445 100644 --- a/frontend/src/features/expenses/list/AddExpensesListButton/hooks/useAddExpensesListButton.ts +++ b/frontend/src/features/expenses/list/AddExpensesListButton/hooks/useAddExpensesListButton.ts @@ -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; }; @@ -21,16 +23,9 @@ export const useAddExpensesListButton = (args: UseAddExpensesListButtonArgs) => setIsOpen(true); }; - const onFormSubmitHandler: FormEventHandler = async (event) => { - event.preventDefault(); - - const formData = new FormData(event.currentTarget); - const title = formData.get('title'); - - if (typeof title === 'string') { - await addExpensesListMutation({ title }); - setIsOpen(false); - } + const onFormSubmitHandler = async (data: AddExpenseListFormValues) => { + await addExpensesListMutation(data); + setIsOpen(false); }; return { diff --git a/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.test.tsx b/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.test.tsx new file mode 100644 index 0000000..ff0d172 --- /dev/null +++ b/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.test.tsx @@ -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( + , + { 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( + '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(); + }); + }); +}); diff --git a/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.ui.tsx b/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.ui.tsx index 59753d6..a00103b 100644 --- a/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.ui.tsx +++ b/frontend/src/features/expenses/list/AddExpensesListButton/ui/AddExpensesListButton.ui.tsx @@ -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 = (props) => { -
-

Добавить список расходов

- - - - -
+
); diff --git a/frontend/src/features/expenses/list/DeleteExpensesListButton/hooks/useDeleteExpensesListButton.ts b/frontend/src/features/expenses/list/DeleteExpensesListButton/hooks/useDeleteExpensesListButton.ts index d54f922..6731847 100644 --- a/frontend/src/features/expenses/list/DeleteExpensesListButton/hooks/useDeleteExpensesListButton.ts +++ b/frontend/src/features/expenses/list/DeleteExpensesListButton/hooks/useDeleteExpensesListButton.ts @@ -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 = async (event) => { - event.preventDefault(); + const onFormSubmitHandler = async () => { await deleteExpensesListAsync({ id }); setIsOpen(false); }; diff --git a/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.test.tsx b/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.test.tsx new file mode 100644 index 0000000..b4cf25f --- /dev/null +++ b/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.test.tsx @@ -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( + , + { 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(`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(); + }); +}); diff --git a/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.ui.tsx b/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.ui.tsx index 3c72b02..c31a674 100644 --- a/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.ui.tsx +++ b/frontend/src/features/expenses/list/DeleteExpensesListButton/ui/DeleteExpensesListButton.ui.tsx @@ -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 & { 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) => { -
-

Удалить список расходов

- -

- Вы действительно хотите удалить список расходов - {' '} - - {`"${title}"`} - - {' '} - ? -

- - -
+
); diff --git a/frontend/src/features/expenses/list/EditExpensesListButton/hooks/useEditExpensesListButton.ts b/frontend/src/features/expenses/list/EditExpensesListButton/hooks/useEditExpensesListButton.ts index 1cdcf53..e6002d4 100644 --- a/frontend/src/features/expenses/list/EditExpensesListButton/hooks/useEditExpensesListButton.ts +++ b/frontend/src/features/expenses/list/EditExpensesListButton/hooks/useEditExpensesListButton.ts @@ -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 = async (event) => { - event.preventDefault(); - const formData = new FormData(event.currentTarget); - const title = formData.get('title'); - - if (typeof title === 'string') { - await editExpensesListAsync({ - path: { id }, - data: { title }, - }); - setIsOpen(false); - } + const onFormSubmitHandler = async (data: EditExpenseListFormValues) => { + await editExpensesListAsync({ + path: { id }, + data, + }); + setIsOpen(false); }; return { diff --git a/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.test.tsx b/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.test.tsx new file mode 100644 index 0000000..c6a4124 --- /dev/null +++ b/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.test.tsx @@ -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( + , + { 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(); + }); + }); +}); diff --git a/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.ui.tsx b/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.ui.tsx index 2204e4a..9fdaf15 100644 --- a/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.ui.tsx +++ b/frontend/src/features/expenses/list/EditExpensesListButton/ui/EditExpensesListButton.ui.tsx @@ -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 = (props) => { -
-

Редактировать список расходов

- - - - -
+
); diff --git a/frontend/src/shared/ui/Modal/ui/Modal.ui.tsx b/frontend/src/shared/ui/Modal/ui/Modal.ui.tsx index 0648d05..a2f6d51 100644 --- a/frontend/src/shared/ui/Modal/ui/Modal.ui.tsx +++ b/frontend/src/shared/ui/Modal/ui/Modal.ui.tsx @@ -16,7 +16,7 @@ const Modal: FC = (props) => { } return ( -
+
{ e.stopPropagation(); }}> ×