diff --git a/frontend/__mocks__/jest/helpers/enqueueSnackbar.ts b/frontend/__mocks__/jest/helpers/enqueueSnackbar.ts new file mode 100644 index 0000000..c730165 --- /dev/null +++ b/frontend/__mocks__/jest/helpers/enqueueSnackbar.ts @@ -0,0 +1,13 @@ +const mockEnqueueSnackbar = jest.fn(); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('notistack', () => ({ + ...jest.requireActual('notistack'), + enqueueSnackbar: mockEnqueueSnackbar, +})); + +beforeEach(() => { + mockEnqueueSnackbar.mockClear(); +}); + +export { mockEnqueueSnackbar }; diff --git a/frontend/__mocks__/jest/helpers/index.ts b/frontend/__mocks__/jest/helpers/index.ts index 5fa8138..5c4356e 100644 --- a/frontend/__mocks__/jest/helpers/index.ts +++ b/frontend/__mocks__/jest/helpers/index.ts @@ -1,3 +1,4 @@ +export { mockEnqueueSnackbar } from './enqueueSnackbar'; export { selectOption } from './selectOption'; export { typeDateField } from './typeDateField'; export { typeTextField } from './typeTextField'; diff --git a/frontend/__mocks__/jest/helpers/selectOption.ts b/frontend/__mocks__/jest/helpers/selectOption.ts index 5143e47..67cd6d2 100644 --- a/frontend/__mocks__/jest/helpers/selectOption.ts +++ b/frontend/__mocks__/jest/helpers/selectOption.ts @@ -1,7 +1,7 @@ import { screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -export const selectOption = async (selectElement: HTMLElement | null, value: string) => { +export const selectOption = async (selectElement: HTMLElement | null, value?: string) => { if (!selectElement) { throw new Error('Не найден элемент выпадающего списка'); } diff --git a/frontend/__mocks__/jest/wrappers/AppWrapper.tsx b/frontend/__mocks__/jest/wrappers/AppWrapper.tsx index 342cf5f..90e4f1b 100644 --- a/frontend/__mocks__/jest/wrappers/AppWrapper.tsx +++ b/frontend/__mocks__/jest/wrappers/AppWrapper.tsx @@ -1,5 +1,7 @@ import type { PropsWithChildren } from 'react'; +import { SnackbarProvider } from 'notistack'; + import { createLocalizationWrapper } from './LocalizationWrapper'; import { createReactQueryWrapper } from './ReactQueryWrapper'; @@ -10,6 +12,8 @@ export const createAppWrapper = () => ({ children }: PropsWithChildren) => { return ( + + {children} diff --git a/frontend/configs/jest/setupEnv.ts b/frontend/configs/jest/setupEnv.ts deleted file mode 100644 index c3daa26..0000000 --- a/frontend/configs/jest/setupEnv.ts +++ /dev/null @@ -1 +0,0 @@ -process.env.TZ = 'Europe/Moscow'; diff --git a/frontend/configs/jest/setupTests.ts b/frontend/configs/jest/setupTests.ts index 4ae9dcf..7603625 100644 --- a/frontend/configs/jest/setupTests.ts +++ b/frontend/configs/jest/setupTests.ts @@ -1,8 +1,6 @@ import '@testing-library/jest-dom'; import { server } from '@mocks/jest/server'; -import './setupEnv'; - beforeAll(() => { server.listen(); }); @@ -12,3 +10,14 @@ afterEach(() => { afterAll(() => { server.close(); }); + +beforeEach(() => { + jest.useFakeTimers({ + advanceTimers: 0.1, + now: new Date('2025-01-01T00:00:00.000Z'), + }); +}); + +afterAll(() => { + jest.useRealTimers(); +}); diff --git a/frontend/eslint.config.ts b/frontend/eslint.config.ts index 0fd21d5..eeb46a7 100644 --- a/frontend/eslint.config.ts +++ b/frontend/eslint.config.ts @@ -12,6 +12,8 @@ export default [ rules: { '@stylistic/jsx-sort-props': 'off', '@typescript-eslint/no-floating-promises': 'off', + '@typescript-eslint/no-misused-promises': 'off', + '@typescript-eslint/prefer-nullish-coalescing': 'off', '@typescript-eslint/sort-type-constituents': 'off', '@typescript-eslint/strict-boolean-expressions': 'off', 'func-style': 'off', diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b086ee4..48bf33e 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@fontsource/roboto": "5.2.8", + "@hookform/resolvers": "5.2.2", "@mui/icons-material": "7.3.4", "@mui/material": "7.3.4", "@mui/x-date-pickers": "8.12.0", @@ -18,9 +19,12 @@ "axios": "1.12.2", "date-fns": "4.1.0", "date-fns-tz": "3.2.0", + "notistack": "3.0.2", "react": "19.2.0", "react-dom": "19.2.0", - "react-router": "7.9.4" + "react-hook-form": "7.64.0", + "react-router": "7.9.4", + "zod": "4.1.12" }, "devDependencies": { "@rsbuild/core": "1.5.14", @@ -1102,6 +1106,18 @@ "url": "https://github.com/sponsors/ayuhito" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -3582,6 +3598,12 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@stylistic/eslint-plugin": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.3.1.tgz", @@ -7969,6 +7991,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/goober": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/goober/-/goober-2.1.18.tgz", + "integrity": "sha512-2vFqsaDVIT9Gz7N6kAL++pLpp41l3PfDuusHcjnGLfR6+huZkl6ziX+zgVC3ZxpqWhzH6pyDdGrCeDhMIvwaxw==", + "license": "MIT", + "peerDependencies": { + "csstype": "^3.0.10" + } + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -11359,6 +11390,37 @@ "node": ">=0.10.0" } }, + "node_modules/notistack": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/notistack/-/notistack-3.0.2.tgz", + "integrity": "sha512-0R+/arLYbK5Hh7mEfR2adt0tyXJcCC9KkA2hc56FeWik2QN6Bm/S4uW+BjzDARsJth5u06nTjelSw/VSnB1YEA==", + "license": "MIT", + "dependencies": { + "clsx": "^1.1.0", + "goober": "^2.0.33" + }, + "engines": { + "node": ">=12.0.0", + "npm": ">=6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/notistack" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/notistack/node_modules/clsx": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-1.2.1.tgz", + "integrity": "sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/npm-run-path": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", @@ -12041,6 +12103,22 @@ "react": "^19.2.0" } }, + "node_modules/react-hook-form": { + "version": "7.64.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.64.0.tgz", + "integrity": "sha512-fnN+vvTiMLnRqKNTVhDysdrUay0kUUAymQnFIznmgDvapjveUWOOPqMNzPg+A+0yf9DuE2h6xzBjN1s+Qx8wcg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -14180,6 +14258,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 78fca24..d2edd56 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@emotion/react": "11.14.0", "@emotion/styled": "11.14.1", "@fontsource/roboto": "5.2.8", + "@hookform/resolvers": "5.2.2", "@mui/icons-material": "7.3.4", "@mui/material": "7.3.4", "@mui/x-date-pickers": "8.12.0", @@ -24,9 +25,12 @@ "axios": "1.12.2", "date-fns": "4.1.0", "date-fns-tz": "3.2.0", + "notistack": "3.0.2", "react": "19.2.0", "react-dom": "19.2.0", - "react-router": "7.9.4" + "react-hook-form": "7.64.0", + "react-router": "7.9.4", + "zod": "4.1.12" }, "devDependencies": { "@rsbuild/core": "1.5.14", diff --git a/frontend/src/app/App/ui/App.tsx b/frontend/src/app/App/ui/App.tsx index 17565da..84cd8cc 100644 --- a/frontend/src/app/App/ui/App.tsx +++ b/frontend/src/app/App/ui/App.tsx @@ -1,17 +1,25 @@ -import './normalize.css'; +import CssBaseline from '@mui/material/CssBaseline'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ru } from 'date-fns/locale/ru'; +import { SnackbarProvider } from 'notistack'; import { AuthContextProvider } from '@/shared/context/AuthContext'; +import { setupZod } from '@/shared/utils'; import Routing from './Routing'; +setupZod(); + const queryClient = new QueryClient(); const App = () => ( + + + + diff --git a/frontend/src/app/App/ui/normalize.css b/frontend/src/app/App/ui/normalize.css deleted file mode 100644 index b7104b6..0000000 --- a/frontend/src/app/App/ui/normalize.css +++ /dev/null @@ -1,353 +0,0 @@ -/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ - -/* Document - ========================================================================== */ - -/** - * 1. Correct the line height in all browsers. - * 2. Prevent adjustments of font size after orientation changes in iOS. - */ - -html { - line-height: 1.15; /* 1 */ - -webkit-text-size-adjust: 100%; /* 2 */ -} - -/* Sections - ========================================================================== */ - -/** - * Remove the margin in all browsers. - */ - -body { - margin: 0; -} - -/** - * Render the `main` element consistently in IE. - */ - -main { - display: block; -} - -/** - * Correct the font size and margin on `h1` elements within `section` and - * `article` contexts in Chrome, Firefox, and Safari. - */ - -h1 { - font-size: 2em; - margin: 0.67em 0; -} - -/* Grouping content - ========================================================================== */ - -/** - * 1. Add the correct box sizing in Firefox. - * 2. Show the overflow in Edge and IE. - */ - -hr { - box-sizing: content-box; /* 1 */ - height: 0; /* 1 */ - overflow: visible; /* 2 */ -} - -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ - -pre { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} - -/* Text-level semantics - ========================================================================== */ - -/** - * Remove the gray background on active links in IE 10. - */ - -a { - background-color: transparent; -} - -/** - * 1. Remove the bottom border in Chrome 57- - * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. - */ - -abbr[title] { - border-bottom: none; /* 1 */ - text-decoration: underline; /* 2 */ - text-decoration: underline dotted; /* 2 */ -} - -/** - * Add the correct font weight in Chrome, Edge, and Safari. - */ - -b, -strong { - font-weight: bolder; -} - -/** - * 1. Correct the inheritance and scaling of font size in all browsers. - * 2. Correct the odd `em` font sizing in all browsers. - */ - -code, -kbd, -samp { - font-family: monospace, monospace; /* 1 */ - font-size: 1em; /* 2 */ -} - -/** - * Add the correct font size in all browsers. - */ - -small { - font-size: 80%; -} - -/** - * Prevent `sub` and `sup` elements from affecting the line height in - * all browsers. - */ - -sub, -sup { - font-size: 75%; - line-height: 0; - position: relative; - vertical-align: baseline; -} - -sub { - bottom: -0.25em; -} - -sup { - top: -0.5em; -} - -/* Embedded content - ========================================================================== */ - -/** - * Remove the border on images inside links in IE 10. - */ - -img { - border-style: none; -} - -/* Forms - ========================================================================== */ - -/** - * 1. Change the font styles in all browsers. - * 2. Remove the margin in Firefox and Safari. - */ - -button, -input, -optgroup, -select, -textarea { - font-family: inherit; /* 1 */ - font-size: 100%; /* 1 */ - line-height: 1.15; /* 1 */ - margin: 0; /* 2 */ -} - -/** - * Show the overflow in IE. - * 1. Show the overflow in Edge. - */ - -button, -input { /* 1 */ - overflow: visible; -} - -/** - * Remove the inheritance of text transform in Edge, Firefox, and IE. - * 1. Remove the inheritance of text transform in Firefox. - */ - -button, -select { /* 1 */ - text-transform: none; -} - -/** - * Correct the inability to style clickable types in iOS and Safari. - */ - -button, -[type="button"], -[type="reset"], -[type="submit"] { - -webkit-appearance: button; -} - -/** - * Remove the inner border and padding in Firefox. - */ - -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner, -[type="reset"]::-moz-focus-inner, -[type="submit"]::-moz-focus-inner { - border-style: none; - padding: 0; -} - -/** - * Restore the focus styles unset by the previous rule. - */ - -button:-moz-focusring, -[type="button"]:-moz-focusring, -[type="reset"]:-moz-focusring, -[type="submit"]:-moz-focusring { - outline: 1px dotted ButtonText; -} - -/** - * Correct the padding in Firefox. - */ - -fieldset { - padding: 0.35em 0.75em 0.625em; -} - -/** - * 1. Correct the text wrapping in Edge and IE. - * 2. Correct the color inheritance from `fieldset` elements in IE. - * 3. Remove the padding so developers are not caught out when they zero out - * `fieldset` elements in all browsers. - */ - -legend { - box-sizing: border-box; /* 1 */ - color: inherit; /* 2 */ - display: table; /* 1 */ - max-width: 100%; /* 1 */ - padding: 0; /* 3 */ - white-space: normal; /* 1 */ -} - -/** - * Add the correct vertical alignment in Chrome, Firefox, and Opera. - */ - -progress { - vertical-align: baseline; -} - -/** - * Remove the default vertical scrollbar in IE 10+. - */ - -textarea { - overflow: auto; -} - -/** - * 1. Add the correct box sizing in IE 10. - * 2. Remove the padding in IE 10. - */ - -[type="checkbox"], -[type="radio"] { - box-sizing: border-box; /* 1 */ - padding: 0; /* 2 */ -} - -/** - * Correct the cursor style of increment and decrement buttons in Chrome. - */ - -[type="number"]::-webkit-inner-spin-button, -[type="number"]::-webkit-outer-spin-button { - height: auto; -} - -/** - * 1. Correct the odd appearance in Chrome and Safari. - * 2. Correct the outline style in Safari. - */ - -[type="search"] { - -webkit-appearance: textfield; /* 1 */ - outline-offset: -2px; /* 2 */ -} - -/** - * Remove the inner padding in Chrome and Safari on macOS. - */ - -[type="search"]::-webkit-search-decoration { - -webkit-appearance: none; -} - -/** - * 1. Correct the inability to style clickable types in iOS and Safari. - * 2. Change font properties to `inherit` in Safari. - */ - -::-webkit-file-upload-button { - -webkit-appearance: button; /* 1 */ - font: inherit; /* 2 */ -} - -/* Interactive - ========================================================================== */ - -/* - * Add the correct display in Edge, IE 10+, and Firefox. - */ - -details { - display: block; -} - -/* - * Add the correct display in all browsers. - */ - -summary { - display: list-item; -} - -/* Misc - ========================================================================== */ - -/** - * Add the correct display in IE 10+. - */ - -template { - display: none; -} - -/** - * Add the correct display in IE 10. - */ - -[hidden] { - display: none; -} - -* { - box-sizing: border-box; -} diff --git a/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/index.ts b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/index.ts new file mode 100644 index 0000000..27dbfde --- /dev/null +++ b/frontend/src/entity/expenses/item/ui/AddExpenseItemForm/index.ts @@ -0,0 +1,2 @@ +export type { AddExpenseItemFormProps, AddExpenseItemFormValues } from './AddExpenseItemForm.ui'; +export { default as AddExpenseItemForm } from './AddExpenseItemForm.ui'; diff --git a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx index 34d229c..a46fa8d 100644 --- a/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx +++ b/frontend/src/entity/expenses/item/ui/EditExpenseItemForm/EditExpenseItemForm.ui.tsx @@ -41,7 +41,6 @@ const EditExpenseItemForm: FC = (props) => { ...formProps } = props; - // eslint-disable-next-line @typescript-eslint/no-misused-promises const onFormSubmitHandler: FormEventHandler = async (event) => { event.preventDefault(); diff --git a/frontend/src/entity/incomes/item/index.ts b/frontend/src/entity/incomes/item/index.ts index a9d46bb..ff10452 100644 --- a/frontend/src/entity/incomes/item/index.ts +++ b/frontend/src/entity/incomes/item/index.ts @@ -7,13 +7,7 @@ export { useDeleteIncomeItem } from './hooks/useDeleteIncomeItem'; export { useEditIncomeItem } from './hooks/useEditIncomeItem'; export type { IncomeListItem } from './types'; -export type { AddIncomeItemFormValues } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui'; -export type { AddIncomeItemFormProps } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui'; -export { default as AddIncomeItemForm } from './ui/AddIncomeItemForm/AddIncomeItemForm.ui'; -export type { DeleteIncomeItemFormProps } from './ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui'; -export { default as DeleteIncomeItemForm } from './ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui'; - -export type { EditIncomeItemFormProps, EditIncomeItemFormValues } from './ui/EditIncomeItemForm/EditIncomeItemForm.ui'; - -export { default as EditIncomeItemForm } from './ui/EditIncomeItemForm/EditIncomeItemForm.ui'; +export * from './ui/AddIncomeItemForm'; +export * from './ui/DeleteIncomeItemForm'; +export * from './ui/EditIncomeItemForm'; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css index a97ffec..2f295bc 100644 --- a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.module.css @@ -1,5 +1,5 @@ .form { display: flex; flex-direction: column; - gap: 10px; + gap: 14px; } diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.schema.ts b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.schema.ts new file mode 100644 index 0000000..6915b0d --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.schema.ts @@ -0,0 +1,13 @@ +import * as z from 'zod'; + +export const AddIncomeItemFormSchema = z.object({ + amount: z.number(), + 'bank-card': z.string().optional(), + cash: z.string().optional(), + date: z.date(), + description: z.string().trim().optional(), + targetType: z.enum(['BANK_CARD', 'CASH', 'none']), + title: z.string().trim().min(5), +}); + +export type AddIncomeItemFormValues = z.infer; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx index a0a3e69..66d5e51 100644 --- a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.test.tsx @@ -1,3 +1,4 @@ +import { bankCardList, cashList } from '@mocks/browser/data'; import { AddIncomeItemFormHelper } from '@tests/helpers'; describe('Test AddIncomeItemForm', () => { @@ -13,27 +14,95 @@ describe('Test AddIncomeItemForm', () => { ['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }], ])('should render fields %s', (_state, options, assertion) => { AddIncomeItemFormHelper.renderForm(options); - const { formElem, ...formElems } = AddIncomeItemFormHelper.getFormElements(); - Object.values(formElems).forEach(assertion); + const elems = AddIncomeItemFormHelper.getFormElements(); + Object.values([ + elems.amountInputElem, + elems.dateInputElem, + elems.descriptionInputElem, + elems.submitButtonElem, + elems.titleInputElem, + ]).forEach(assertion); }); - test('should call onSubmit with values', async () => { - const { onSubmit } = AddIncomeItemFormHelper.renderForm(); + describe('should call onSubmit with values', () => { + const setup = async () => { + const { onSubmit } = AddIncomeItemFormHelper.renderForm(); - await AddIncomeItemFormHelper.fillForm({ - amount: 12, - date: '29.08.2025', - description: 'Описание элемента', - title: 'Название элемента', + await AddIncomeItemFormHelper.fillForm( + { + amount: 12, + date: '29.08.2025', + description: 'Описание элемента', + targetType: 'none', + title: 'Название элемента', + }, + { autoSubmit: false }, + ); + + return { + onSubmit, + }; + }; + + test('no payment', async () => { + const { onSubmit } = await setup(); + await AddIncomeItemFormHelper.submitForm(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + amount: 12, + date: new Date('2025-08-29T00:00:00.000Z'), + description: 'Описание элемента', + targetType: 'none', + title: 'Название элемента', + }); }); - expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith({ - amount: 12, - currency: 'RUB', - date: '2025-08-29T00:00:00.000Z', - description: 'Описание элемента', - title: 'Название элемента', + test('bank card payment', async () => { + const { onSubmit } = await setup(); + await AddIncomeItemFormHelper.fillForm({ targetType: 'BANK_CARD' }, { autoSubmit: false }); + + const { bankCardSelect, bankCardSelectInput } = AddIncomeItemFormHelper.getFormElements(); + expect(bankCardSelect).toBeVisible(); + expect(bankCardSelectInput).toBeEnabled(); + expect(bankCardSelectInput).toHaveValue(bankCardList[0].id); + + await AddIncomeItemFormHelper.fillForm({ 'bank-card': bankCardList[1].id, targetType: 'BANK_CARD' }, { autoSubmit: false }); + expect(bankCardSelectInput).toHaveValue(bankCardList[1].id); + + await AddIncomeItemFormHelper.submitForm(); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + amount: 12, + 'bank-card': bankCardList[1].id, + date: new Date('2025-08-29T00:00:00.000Z'), + description: 'Описание элемента', + targetType: 'BANK_CARD', + title: 'Название элемента', + }); + }); + + test('cash payment', async () => { + const { onSubmit } = await setup(); + + await AddIncomeItemFormHelper.fillForm({ targetType: 'CASH' }); + const { cashSelect, cashSelectInput } = AddIncomeItemFormHelper.getFormElements(); + expect(cashSelect).toBeVisible(); + expect(cashSelectInput).toBeEnabled(); + expect(cashSelectInput).toHaveValue(cashList[0].id); + + await AddIncomeItemFormHelper.fillForm({ cash: cashList[1].id, targetType: 'CASH' }); + + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSubmit).toHaveBeenCalledWith({ + amount: 12, + cash: cashList[1].id, + date: new Date('2025-08-29T00:00:00.000Z'), + description: 'Описание элемента', + targetType: 'CASH', + title: 'Название элемента', + }); }); }); }); diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx index 3cd05f2..de29c69 100644 --- a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/AddIncomeItemForm.ui.tsx @@ -1,32 +1,30 @@ import type { FC, - FormEventHandler, FormHTMLAttributes, RefObject, } from 'react'; -import { Button, TextField, Typography } from '@mui/material'; -import { DatePicker } from '@mui/x-date-pickers'; +import { Button, Typography } from '@mui/material'; +import { FormProvider } from 'react-hook-form'; import { DataTestId } from '@/shared/constants'; -import { getUTCDate } from '@/shared/utils'; + +import type { UseAddIncomeItemFormArgs } from './hooks/useAddIncomeItemForm'; import classes from './AddIncomeItemForm.module.css'; +import AmountField from './fields/AmountField'; +import BankCardField from './fields/BankCardField'; +import CashField from './fields/CashField'; +import DateField from './fields/DateField'; +import DescriptionField from './fields/DescriptionField'; +import TargetTypeField from './fields/TargetTypeField'; +import TitleField from './fields/TitleField'; +import { useAddIncomeItemForm } from './hooks/useAddIncomeItemForm'; -export type AddIncomeItemFormProps = BaseFormProps & { - disabled?: boolean; - onSubmit?: (data: AddIncomeItemFormValues) => Promise | void; +export type AddIncomeItemFormProps = BaseFormProps & UseAddIncomeItemFormArgs & { ref?: RefObject; }; -export type AddIncomeItemFormValues = { - amount: number; - currency: string; - date: string; - description: string; - title: string; -}; - type BaseFormProps = Omit, 'onSubmit'>; const AddIncomeItemForm: FC = (props) => { @@ -37,82 +35,53 @@ const AddIncomeItemForm: FC = (props) => { ...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'); - - if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string') { - onSubmit?.({ - amount: Number(amount), - currency: 'RUB', - date: getUTCDate(date), - description, - title, - }); - } - }; + const { + formState, + handleSubmit, + methods, + onFormSubmitHandler, + watch, + } = useAddIncomeItemForm({ disabled, onSubmit }); return ( -
- - Добавить список доходов - - - - - - - - - - - - + + Добавить элемент дохода + + + + + + + + + + + + + {watch('targetType') === 'BANK_CARD' && } + + {watch('targetType') === 'CASH' && } + + + + ); }; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/AmountField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/AmountField.tsx new file mode 100644 index 0000000..85b7193 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/AmountField.tsx @@ -0,0 +1,33 @@ +import { TextField } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +const AmountField = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + { + field.onChange(Number(event.target.value)); + }} + required + size="small" + type="number" + /> + )} + /> + ); +}; + +export default AmountField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/BankCardField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/BankCardField.tsx new file mode 100644 index 0000000..ca58b7d --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/BankCardField.tsx @@ -0,0 +1,58 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useBankCards } from '@/entity/cards'; +import { formatRub } from '@/shared/utils'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +const BankCardField = () => { + const { control, formState } = useFormContext>(); + + const { data: bankCards } = useBankCards(); + + const bankCardOptions = bankCards?.map((card) => ( + + {`${card.name} (${formatRub(card.balance)})`} + + )); + + return ( + ( + + Карта + + + + + {formState.errors['bank-card']?.message} + + + )} + /> + ); +}; + +export default BankCardField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/CashField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/CashField.tsx new file mode 100644 index 0000000..3ac4396 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/CashField.tsx @@ -0,0 +1,57 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useCash } from '@/entity/cash'; +import { formatRub } from '@/shared/utils'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +const CashField = () => { + const { control, formState } = useFormContext>(); + const { data: cash } = useCash(); + + const cashOptions = cash?.map((cashItem) => ( + + {`${cashItem.name} (${formatRub(cashItem.balance)})`} + + )); + + return ( + ( + + Наличные + + + + + {formState.errors.cash?.message} + + + )} + /> + ); +}; + +export default CashField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DateField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DateField.tsx new file mode 100644 index 0000000..d7af5a3 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DateField.tsx @@ -0,0 +1,37 @@ +import type { FC } from 'react'; + +import { DatePicker } from '@mui/x-date-pickers'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { AddIncomeItemFormValues } from '@/entity/incomes/item'; + +const DateField: FC = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + + )} + /> + ); +}; + +export default DateField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DescriptionField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DescriptionField.tsx new file mode 100644 index 0000000..9d095f2 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/DescriptionField.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react'; + +import { TextField } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +const DescriptionField: FC = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + + )} + /> + ); +}; + +export default DescriptionField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TargetTypeField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TargetTypeField.tsx new file mode 100644 index 0000000..47ce5cb --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TargetTypeField.tsx @@ -0,0 +1,69 @@ +import type { FC } from 'react'; + +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { useMemo } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useBankCards } from '@/entity/cards'; +import { useCash } from '@/entity/cash'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +const TargetTypeField: FC = () => { + const { control, formState } = useFormContext>(); + + const { data: bankCards } = useBankCards(); + const { data: cash } = useCash(); + + const targetTypeOptions = useMemo(() => { + const options = [Без оплаты]; + + if (bankCards && bankCards.length > 0) { + options.push(Банковская карта); + } + + if (cash && cash.length > 0) { + options.push(Наличные); + } + + return options; + }, [bankCards, cash]); + + return ( + ( + + Способ получения + + + + + {formState.errors.targetType?.message} + + + )} + /> + ); +}; + +export default TargetTypeField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TitleField.tsx b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TitleField.tsx new file mode 100644 index 0000000..7bd5213 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/fields/TitleField.tsx @@ -0,0 +1,30 @@ +import type { FC } from 'react'; + +import { TextField } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +const TitleField: FC = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + + )} + /> + ); +}; + +export default TitleField; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/hooks/useAddIncomeItemForm.ts b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/hooks/useAddIncomeItemForm.ts new file mode 100644 index 0000000..d7fbb53 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/hooks/useAddIncomeItemForm.ts @@ -0,0 +1,47 @@ +import type { SubmitHandler } from 'react-hook-form'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import type { AddIncomeItemFormValues } from '../AddIncomeItemForm.schema'; + +import { AddIncomeItemFormSchema } from '../AddIncomeItemForm.schema'; + +export type UseAddIncomeItemFormArgs = { + disabled?: boolean; + onSubmit: (data: AddIncomeItemFormValues) => Promise | void; +}; + +export const useAddIncomeItemForm = (args: UseAddIncomeItemFormArgs) => { + const { disabled, onSubmit } = args; + + const methods = useForm({ + defaultValues: { + amount: 0, + date: new Date(), + description: '', + targetType: 'none', + title: '', + }, + disabled, + resolver: zodResolver(AddIncomeItemFormSchema), + shouldUnregister: true, + }); + const { + formState, + handleSubmit, + watch, + } = methods; + + const onFormSubmitHandler: SubmitHandler = async (data) => { + await onSubmit(data); + }; + + return { + formState, + handleSubmit, + methods, + onFormSubmitHandler, + watch, + }; +}; diff --git a/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/index.ts b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/index.ts new file mode 100644 index 0000000..3abbcc1 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/AddIncomeItemForm/index.ts @@ -0,0 +1,5 @@ +export type { AddIncomeItemFormValues } from './AddIncomeItemForm.schema'; +export { AddIncomeItemFormSchema } from './AddIncomeItemForm.schema'; + +export type { AddIncomeItemFormProps } from './AddIncomeItemForm.ui'; +export { default as AddIncomeItemForm } from './AddIncomeItemForm.ui'; diff --git a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx index 9fb49e4..3c303fc 100644 --- a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx +++ b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.test.tsx @@ -13,6 +13,6 @@ describe('Test DeleteIncomeItemForm', () => { await DeleteIncomeItemFormHelper.submitForm(); expect(onSubmit).toHaveBeenCalledTimes(1); - expect(onSubmit).toHaveBeenCalledWith(DeleteIncomeItemFormHelper.incomeItem); + expect(onSubmit).toHaveBeenCalledWith(); }); }); diff --git a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx index cb6f1e3..c122a5a 100644 --- a/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx +++ b/frontend/src/entity/incomes/item/ui/DeleteIncomeItemForm/DeleteIncomeItemForm.ui.tsx @@ -1,6 +1,5 @@ import type { FC, - FormEventHandler, FormHTMLAttributes, RefObject, } from 'react'; @@ -9,12 +8,11 @@ import { Button, Typography } from '@mui/material'; import { DataTestId } from '@/shared/constants'; -import type { IncomeListItem } from '../../types'; +import type { UseDeleteIncomeItemFormArgs } from './hooks/useDeleteIncomeItemForm'; -export type DeleteIncomeItemFormProps = BaseFormProps & { - disabled?: boolean; - item: IncomeListItem; - onSubmit?: (item: IncomeListItem) => Promise | void; +import { useDeleteIncomeItemForm } from './hooks/useDeleteIncomeItemForm'; + +export type DeleteIncomeItemFormProps = BaseFormProps & UseDeleteIncomeItemFormArgs & { ref?: RefObject; }; @@ -23,20 +21,17 @@ type BaseFormProps = Omit, 'onSubmit'>; const DeleteIncomeItemForm: FC = (props) => { const { disabled, - item, onSubmit, ref, ...restProps } = props; - const onFormSubmitHandler: FormEventHandler = (event) => { - event.preventDefault(); - onSubmit?.(item); - }; + + const { formState, handleSubmit, onFormSubmitHandler } = useDeleteIncomeItemForm({ disabled, onSubmit }); return (
@@ -47,7 +42,8 @@ const DeleteIncomeItemForm: FC = (props) => { - + + Изменить доход + + + + + + + + + + + + + {watch('targetType') === 'BANK_CARD' && } + + {watch('targetType') === 'CASH' && } + + + + ); }; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/AmountField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/AmountField.tsx new file mode 100644 index 0000000..b5f4a1a --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/AmountField.tsx @@ -0,0 +1,33 @@ +import { TextField } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const AmountField = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + { + field.onChange(Number(event.target.value)); + }} + required + size="small" + type="number" + /> + )} + /> + ); +}; + +export default AmountField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/BankCardField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/BankCardField.tsx new file mode 100644 index 0000000..903a571 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/BankCardField.tsx @@ -0,0 +1,58 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useBankCards } from '@/entity/cards'; +import { formatRub } from '@/shared/utils'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const BankCardField = () => { + const { control, formState } = useFormContext>(); + + const { data: bankCards } = useBankCards(); + + const bankCardOptions = bankCards?.map((card) => ( + + {`${card.name} (${formatRub(card.balance)})`} + + )); + + return ( + ( + + Карта + + + + + {formState.errors['bank-card']?.message} + + + )} + /> + ); +}; + +export default BankCardField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/CashField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/CashField.tsx new file mode 100644 index 0000000..6caebc9 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/CashField.tsx @@ -0,0 +1,57 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useCash } from '@/entity/cash'; +import { formatRub } from '@/shared/utils'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const CashField = () => { + const { control, formState } = useFormContext>(); + const { data: cash } = useCash(); + + const cashOptions = cash?.map((cashItem) => ( + + {`${cashItem.name} (${formatRub(cashItem.balance)})`} + + )); + + return ( + ( + + Наличные + + + + + {formState.errors.cash?.message} + + + )} + /> + ); +}; + +export default CashField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DateField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DateField.tsx new file mode 100644 index 0000000..8316907 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DateField.tsx @@ -0,0 +1,37 @@ +import type { FC } from 'react'; + +import { DatePicker } from '@mui/x-date-pickers'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const DateField: FC = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + + )} + /> + ); +}; + +export default DateField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DescriptionField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DescriptionField.tsx new file mode 100644 index 0000000..b6af784 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/DescriptionField.tsx @@ -0,0 +1,29 @@ +import type { FC } from 'react'; + +import { TextField } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const DescriptionField: FC = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + + )} + /> + ); +}; + +export default DescriptionField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TargetTypeField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TargetTypeField.tsx new file mode 100644 index 0000000..2a7ec51 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TargetTypeField.tsx @@ -0,0 +1,69 @@ +import type { FC } from 'react'; + +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, +} from '@mui/material'; +import { useMemo } from 'react'; +import { Controller, useFormContext } from 'react-hook-form'; + +import { useBankCards } from '@/entity/cards'; +import { useCash } from '@/entity/cash'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const TargetTypeField: FC = () => { + const { control, formState } = useFormContext>(); + + const { data: bankCards } = useBankCards(); + const { data: cash } = useCash(); + + const targetTypeOptions = useMemo(() => { + const options = [Без оплаты]; + + if (bankCards && bankCards.length > 0) { + options.push(Банковская карта); + } + + if (cash && cash.length > 0) { + options.push(Наличные); + } + + return options; + }, [bankCards, cash]); + + return ( + ( + + Способ получения + + + + + {formState.errors.targetType?.message} + + + )} + /> + ); +}; + +export default TargetTypeField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TitleField.tsx b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TitleField.tsx new file mode 100644 index 0000000..b9398a7 --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/fields/TitleField.tsx @@ -0,0 +1,30 @@ +import type { FC } from 'react'; + +import { TextField } from '@mui/material'; +import { Controller, useFormContext } from 'react-hook-form'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +const TitleField: FC = () => { + const { control, formState } = useFormContext>(); + + return ( + ( + + )} + /> + ); +}; + +export default TitleField; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/hooks/useEditIncomeItemForm.ts b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/hooks/useEditIncomeItemForm.ts new file mode 100644 index 0000000..07dc1fc --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/hooks/useEditIncomeItemForm.ts @@ -0,0 +1,54 @@ +import type { SubmitHandler } from 'react-hook-form'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; + +import type { IncomeListItem } from '@/entity/incomes/item'; + +import type { EditIncomeItemFormValues } from '../EditIncomeItemForm.schema'; + +import { EditIncomeItemFormSchema } from '../EditIncomeItemForm.schema'; + +export type UseEditIncomeItemFormArgs = { + disabled?: boolean; + item: IncomeListItem; + onSubmit: (data: EditIncomeItemFormValues) => Promise | void; +}; + +export const useEditIncomeItemForm = (args: UseEditIncomeItemFormArgs) => { + const { disabled, item, onSubmit } = args; + + const methods = useForm({ + defaultValues: { + amount: item.amount, + // eslint-disable-next-line no-undefined + 'bank-card': item.target?.type === 'BANK_CARD' ? item.target.id : undefined, + // eslint-disable-next-line no-undefined + cash: item.target?.type === 'CASH' ? item.target.id : undefined, + date: new Date(item.date), + description: item.description ?? '', + targetType: item.target?.type ?? 'none', + title: item.title, + }, + disabled, + resolver: zodResolver(EditIncomeItemFormSchema), + shouldUnregister: true, + }); + const { + formState, + handleSubmit, + watch, + } = methods; + + const onFormSubmitHandler: SubmitHandler = async (data) => { + await onSubmit(data); + }; + + return { + formState, + handleSubmit, + methods, + onFormSubmitHandler, + watch, + }; +}; diff --git a/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/index.ts b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/index.ts new file mode 100644 index 0000000..4db454c --- /dev/null +++ b/frontend/src/entity/incomes/item/ui/EditIncomeItemForm/index.ts @@ -0,0 +1,4 @@ +export type { EditIncomeItemFormValues } from './EditIncomeItemForm.schema'; +export { EditIncomeItemFormSchema } from './EditIncomeItemForm.schema'; +export type { EditIncomeItemFormProps } from './EditIncomeItemForm.ui'; +export { default as EditIncomeItemForm } from './EditIncomeItemForm.ui'; diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts b/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts index 53367d8..1c10a80 100644 --- a/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/hooks/useAddIncomeItemButton.ts @@ -1,5 +1,6 @@ import type { ButtonHTMLAttributes, MouseEventHandler } from 'react'; +import { enqueueSnackbar } from 'notistack'; import { useState } from 'react'; import type { AddIncomeItemFormValues } from '@/entity/incomes/item'; @@ -26,10 +27,29 @@ export const useAddIncomeItemButton = (args: UseAddIncomeItemButtonArgs) => { }; const onFormSubmitHandler = async (data: AddIncomeItemFormValues) => { - // todo добавить обработку ошибок (код + тест) - await addIncomeItemMutation(data); + try { + const target = data.targetType === 'none' + ? null + : { + id: data.targetType === 'BANK_CARD' ? data['bank-card'] : data.cash, + type: data.targetType, + }; + await addIncomeItemMutation({ + amount: data.amount, + currency: 'RUB', + date: data.date.toISOString(), + description: data.description ?? null, + // @ts-ignore TODO + target, + title: data.title, + }); - setIsOpen(false); + enqueueSnackbar(`Доход "${data.title}" успешно добавлен`, { variant: 'success' }); + + setIsOpen(false); + } catch { + enqueueSnackbar('Ошибка при добавлении дохода', { variant: 'error' }); + } }; return { diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx index 0ee21ef..b303a63 100644 --- a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.test.tsx @@ -1,3 +1,4 @@ +import { mockEnqueueSnackbar } from '@mocks/jest/helpers'; import { server } from '@mocks/jest/server'; import { screen, waitFor } from '@testing-library/react'; import { AddIncomeItemButtonHelper, AddIncomeItemFormHelper } from '@tests/helpers'; @@ -22,7 +23,7 @@ describe('Test AddIncomeItemButton', () => { expect(modalElem).toBeVisible(); }); - describe('should close modal after submit and send request', () => { + describe('should close modal after success request', () => { let modalElem: HTMLElement; const requestSpy = jest.fn(); @@ -35,26 +36,56 @@ describe('Test AddIncomeItemButton', () => { date: '29.08.2025', description: 'Описание элемента', title: 'Название элемента', - }, { autoSubmit: false }); + }); }); - test('should send correct request', async () => { - await AddIncomeItemFormHelper.submitForm(); + test('should send correct request', () => { expect(requestSpy).toHaveBeenCalledTimes(1); expect(requestSpy).toHaveBeenCalledWith({ amount: 12, currency: 'RUB', date: '2025-08-29T00:00:00.000Z', description: 'Описание элемента', + target: null, title: 'Название элемента', }); }); test('should close modal after submit', async () => { - await AddIncomeItemFormHelper.submitForm(); await waitFor(() => { expect(modalElem).not.toBeInTheDocument(); }); }); + + test('should show success notification', () => { + expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Доход "Название элемента" успешно добавлен', { variant: 'success' }); + }); + }); + + describe('should not close modal after failed request', () => { + let modalElem: HTMLElement; + const requestSpy = jest.fn(); + + beforeEach(async () => { + server.use(AddIncomeItemButtonHelper.submitHandler(requestSpy, 500)); + modalElem = (await AddIncomeItemButtonHelper.openModal()).modalElem; + + await AddIncomeItemFormHelper.fillForm({ + amount: 12, + date: '29.08.2025', + description: 'Описание элемента', + title: 'Название элемента', + }); + }); + + test('should not close modal after submit', () => { + expect(modalElem).toBeInTheDocument(); + }); + + test('should show error notification', () => { + expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Ошибка при добавлении дохода', { variant: 'error' }); + }); }); }); diff --git a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx index e1e19f7..06bc3a8 100644 --- a/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx +++ b/frontend/src/features/incomes/items/AddIncomeItemButton/ui/AddIncomeItemButton.ui.tsx @@ -44,7 +44,7 @@ const AddIncomeItemButton: FC = (props) => { Добавить - + diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts b/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts index 530f3bf..63a5914 100644 --- a/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/hooks/useDeleteIncomeItemButton.ts @@ -1,5 +1,6 @@ import type { MouseEventHandler } from 'react'; +import { enqueueSnackbar } from 'notistack'; import { useState } from 'react'; import { type IncomeListItem, useDeleteIncomeItem } from '@/entity/incomes/item'; @@ -27,8 +28,14 @@ export const useDeleteIncomeItemButton = (args: UseDeleteIncomeItemButtonArgs) = }); const onFormSubmitHandler = async () => { - await deleteIncomeItemMutation(); - setIsOpen(false); + try { + await deleteIncomeItemMutation(); + + enqueueSnackbar(`Доход "${item.title}" удален`, { variant: 'success' }); + setIsOpen(false); + } catch { + enqueueSnackbar(`Ошибка при удалении дохода "${item.title}"`, { variant: 'error' }); + } }; return { diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx index 6cee691..42007e7 100644 --- a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.test.tsx @@ -1,6 +1,6 @@ +import { mockEnqueueSnackbar } from '@mocks/jest/helpers'; import { server } from '@mocks/jest/server'; import { screen, waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; import { DeleteIncomeItemButtonHelper, DeleteIncomeItemFormHelper } from '@tests/helpers'; describe('Test DeleteIncomeItemButton', () => { @@ -23,15 +23,51 @@ describe('Test DeleteIncomeItemButton', () => { expect(modalElem).toBeVisible(); }); - test('should close modal after submit', async () => { - server.use(DeleteIncomeItemButtonHelper.submitHandler); - const { modalElem } = await DeleteIncomeItemButtonHelper.openModal(); - const { submitButtonElem } = DeleteIncomeItemFormHelper.getFormElements(); + describe('should close modal after success request', () => { + let modalElem: HTMLElement; + const requestSpy = jest.fn(); - expect(submitButtonElem).toBeEnabled(); - await userEvent.click(submitButtonElem); - await waitFor(() => { - expect(modalElem).not.toBeInTheDocument(); + beforeEach(async () => { + server.use(DeleteIncomeItemButtonHelper.submitHandler(requestSpy)); + modalElem = (await DeleteIncomeItemButtonHelper.openModal()).modalElem; + + await DeleteIncomeItemFormHelper.submitForm(); + }); + + test('should send correct request', () => { + expect(requestSpy).toHaveBeenCalledTimes(1); + }); + + test('should close modal after submit', async () => { + await waitFor(() => { + expect(modalElem).not.toBeInTheDocument(); + }); + }); + + test('should show success notification', () => { + expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Доход "Mock item" удален', { variant: 'success' }); + }); + }); + + describe('should not close modal after failed request', () => { + let modalElem: HTMLElement; + const requestSpy = jest.fn(); + + beforeEach(async () => { + server.use(DeleteIncomeItemButtonHelper.submitHandler(requestSpy, 500)); + modalElem = (await DeleteIncomeItemButtonHelper.openModal()).modalElem; + + await DeleteIncomeItemFormHelper.submitForm(); + }); + + test('should not close modal after submit', () => { + expect(modalElem).toBeInTheDocument(); + }); + + test('should show error notification', () => { + expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Ошибка при удалении дохода "Mock item"', { variant: 'error' }); }); }); }); diff --git a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx index 5d43696..a31f8f0 100644 --- a/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx +++ b/frontend/src/features/incomes/items/DeleteIncomeItemButton/ui/DeleteIncomeItemButton.ui.tsx @@ -44,7 +44,7 @@ const DeleteIncomeItemButton: FC = (props) => { - + ); diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts b/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts index fd49c78..f96eade 100644 --- a/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/hooks/useIncomeItemButton.ts @@ -1,5 +1,6 @@ import type { MouseEventHandler } from 'react'; +import { enqueueSnackbar } from 'notistack'; import { useState } from 'react'; import { type EditIncomeItemFormValues, type IncomeListItem, useEditIncomeItem } from '@/entity/incomes/item'; @@ -27,9 +28,29 @@ export const useIncomeItemButton = (args: UseAddIncomeItemButtonArgs) => { }; const onFormSubmitHandler = async (data: EditIncomeItemFormValues) => { - // todo добавить обработку ошибок (код + тест) - await editIncomeItemMutation(data); - setIsOpen(false); + try { + const target = data.targetType === 'none' + ? null + : { + id: data.targetType === 'BANK_CARD' ? data['bank-card'] : data.cash, + type: data.targetType, + }; + await editIncomeItemMutation({ + amount: data.amount, + currency: 'RUB', + date: data.date.toISOString(), + description: data.description ?? null, + // @ts-ignore TODO + target, + title: data.title, + }); + + enqueueSnackbar(`Доход "${data.title}" успешно сохранен`, { variant: 'success' }); + + setIsOpen(false); + } catch { + enqueueSnackbar('Ошибка при сохранении дохода', { variant: 'error' }); + } }; return { diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx index 5761e54..c999593 100644 --- a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.test.tsx @@ -1,3 +1,4 @@ +import { mockEnqueueSnackbar } from '@mocks/jest/helpers'; import { server } from '@mocks/jest/server'; import { screen, waitFor } from '@testing-library/react'; import { EditIncomeItemButtonHelper, EditIncomeItemFormHelper } from '@tests/helpers'; @@ -22,7 +23,7 @@ describe('TestEditIncomeItemButton', () => { expect(modalElem).toBeVisible(); }); - describe('should close modal after submit and send request', () => { + describe('should close modal after success request', () => { let modalElem: HTMLElement; const requestSpy = jest.fn(); @@ -35,26 +36,56 @@ describe('TestEditIncomeItemButton', () => { date: '29.08.2025', description: 'Описание элемента', title: 'Название элемента', - }, { autoSubmit: false }); + }); }); - test('should send correct request', async () => { - await EditIncomeItemFormHelper.submitForm(); + test('should send correct request', () => { expect(requestSpy).toHaveBeenCalledTimes(1); expect(requestSpy).toHaveBeenCalledWith({ amount: 12, currency: 'RUB', date: '2025-08-29T00:00:00.000Z', description: 'Описание элемента', + target: null, title: 'Название элемента', }); }); test('should close modal after submit', async () => { - await EditIncomeItemFormHelper.submitForm(); await waitFor(() => { expect(modalElem).not.toBeInTheDocument(); }); }); + + test('should show success notification', () => { + expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Доход "Название элемента" успешно сохранен', { variant: 'success' }); + }); + }); + + describe('should not close modal after failed request', () => { + let modalElem: HTMLElement; + const requestSpy = jest.fn(); + + beforeEach(async () => { + server.use(EditIncomeItemButtonHelper.submitHandler(requestSpy, 500)); + modalElem = (await EditIncomeItemButtonHelper.openModal()).modalElem; + + await EditIncomeItemFormHelper.fillForm({ + amount: 12, + date: '29.08.2025', + description: 'Описание элемента', + title: 'Название элемента', + }); + }); + + test('should not close modal after submit', () => { + expect(modalElem).toBeInTheDocument(); + }); + + test('should show error notification', () => { + expect(mockEnqueueSnackbar).toHaveBeenCalledTimes(1); + expect(mockEnqueueSnackbar).toHaveBeenCalledWith('Ошибка при сохранении дохода', { variant: 'error' }); + }); }); }); diff --git a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx index 7dcfb4f..d79ff34 100644 --- a/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx +++ b/frontend/src/features/incomes/items/EditIncomeItemButton/ui/EditIncomeItemButton.ui.tsx @@ -45,7 +45,7 @@ const EditIncomeItemButton: FC = (props) => { Редактировать - + diff --git a/frontend/src/pages/Income/ui/IncomePage.ui.tsx b/frontend/src/pages/Income/ui/IncomePage.ui.tsx index ed246a4..e368d83 100644 --- a/frontend/src/pages/Income/ui/IncomePage.ui.tsx +++ b/frontend/src/pages/Income/ui/IncomePage.ui.tsx @@ -32,7 +32,12 @@ const IncomePage = () => { return ( - Списки расходов + Элементы дохода + {' '} + + " + {incomeList?.title} + " diff --git a/frontend/src/shared/api/schema.ts b/frontend/src/shared/api/schema.ts index 56e7757..fd722a1 100644 --- a/frontend/src/shared/api/schema.ts +++ b/frontend/src/shared/api/schema.ts @@ -754,6 +754,19 @@ export interface components { */ title: string; }; + IncomeSourceDto: { + /** + * @description Источник дохода + * @example CASH + * @enum {string} + */ + type: "BANK_CARD" | "CASH"; + /** + * @description ID карты или наличных + * @example 123 + */ + id: string; + }; IncomeListItemDto: { /** @description ID */ id: string; @@ -786,6 +799,8 @@ export interface components { * @example RUB */ currency: string | null; + /** @description Источник */ + target: components["schemas"]["IncomeSourceDto"] | null; /** * Format: date-time * @description Дата создания @@ -854,6 +869,8 @@ export interface components { * @example RUB */ currency: string | null; + /** @description Источник */ + target: components["schemas"]["IncomeSourceDto"] | null; }; EditIncomeListItemDto: { /** diff --git a/frontend/src/shared/utils/index.ts b/frontend/src/shared/utils/index.ts index f2b914b..b50af57 100644 --- a/frontend/src/shared/utils/index.ts +++ b/frontend/src/shared/utils/index.ts @@ -6,3 +6,4 @@ export { formatRub } from './formatters/currency'; export { formatDate } from './formatters/date'; export { getUTCDate } from './helpers/getUTCDate'; +export { setupZod } from './zod/config'; diff --git a/frontend/src/shared/utils/zod/config.ts b/frontend/src/shared/utils/zod/config.ts new file mode 100644 index 0000000..0d46f1b --- /dev/null +++ b/frontend/src/shared/utils/zod/config.ts @@ -0,0 +1,27 @@ +import * as z from 'zod'; + +export const setupZod = () => z.config({ + customError: (issue) => { + if (issue.code === 'invalid_type') { + if (typeof issue.input === 'undefined') { + return { + message: 'Обязательно к заполнению', + }; + } + + return { + message: 'Неверный формат', + }; + } + + if (issue.code === 'too_small') { + return { + message: `Минимум ${issue.minimum} символов`, + }; + } + + return { + message: 'Ошибка', + }; + }, +}); diff --git a/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx b/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx index 72f255a..0354ed1 100644 --- a/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx +++ b/frontend/tests/helpers/income/item/AddIncomeItemButton.tsx @@ -1,3 +1,5 @@ +import { getBankCardList, getCashList } from '@mocks/browser/handlers'; +import { server } from '@mocks/jest/server'; import { createAppWrapper } from '@mocks/jest/wrappers'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -9,6 +11,7 @@ import { AddIncomeItemButton } from '@/features/incomes/items/AddIncomeItemButto import { DataTestId } from '@/shared/constants'; const renderButton = () => { + server.use(getBankCardList, getCashList); render( , { wrapper: createAppWrapper() }, @@ -31,7 +34,7 @@ const openModal = async () => { }; }; -const submitHandler = (requestSpy: jest.Mock) => http.post< +const submitHandler = (requestSpy: jest.Mock, status = 200) => http.post< AddIncomeItemPath, AddIncomeItemBody, AddIncomeItemSuccessResponse @@ -45,13 +48,14 @@ const submitHandler = (requestSpy: jest.Mock) => http.post< amount: 12, createdAt: '2025-08-29T07:57:40.503Z', currency: 'RUB', - date: '2025-08-22T00:00:03.000Z', + date: '2025-08-22T00:00:00.000Z', description: 'Mock description', id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6', incomeListId: '10cc093c-3875-4d33-a40a-df526266e262', + target: null, title: 'Mock item', updatedAt: '2025-08-29T07:57:40.503Z', - }); + }, { status }); }, ); diff --git a/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx b/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx index b6d3352..4f4e76c 100644 --- a/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx +++ b/frontend/tests/helpers/income/item/AddIncomeItemForm.tsx @@ -1,4 +1,6 @@ -import { typeDateField, typeTextField } from '@mocks/jest/helpers'; +import { getBankCardList, getCashList } from '@mocks/browser/handlers'; +import { selectOption, typeDateField, typeTextField } from '@mocks/jest/helpers'; +import { server } from '@mocks/jest/server'; import { createAppWrapper } from '@mocks/jest/wrappers'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -9,8 +11,8 @@ import { AddIncomeItemForm } from '@/entity/incomes/item'; import { DataTestId } from '@/shared/constants'; const renderForm = (props?: Partial) => { + server.use(getBankCardList, getCashList); const onSubmit = jest.fn(); - render( , { wrapper: createAppWrapper() }, @@ -29,12 +31,25 @@ const getFormElements = () => { const amountInputElem = screen.getByLabelText(/Сумма/i); const submitButtonElem = screen.getByTestId(DataTestId.ADD_INCOME_ITEM_SUBMIT_BUTTON); + const targetTypeSelect = screen.getByTestId('target-type-select'); + + const bankCardSelect = screen.queryByTestId('bank-card-select'); + const bankCardSelectInput = screen.queryByTestId('bank-card-select-input'); + + const cashSelect = screen.queryByTestId('cash-select'); + const cashSelectInput = screen.queryByTestId('cash-select-input'); + return { amountInputElem, + bankCardSelect, + bankCardSelectInput, + cashSelect, + cashSelectInput, dateInputElem, descriptionInputElem, formElem, submitButtonElem, + targetTypeSelect, titleInputElem, }; }; @@ -45,13 +60,16 @@ const submitForm = async () => { }; const fillForm = async ( - data: Omit, + data: Partial & { date: string }>, options: { autoSubmit?: boolean } = {}, ) => { const { amount, + 'bank-card': bankCard, + cash, date, description, + targetType, title, } = data; const { autoSubmit = true } = options; @@ -59,13 +77,42 @@ const fillForm = async ( amountInputElem, dateInputElem, descriptionInputElem, + targetTypeSelect, titleInputElem, } = getFormElements(); await typeTextField(titleInputElem, title); await typeTextField(descriptionInputElem, description); await typeDateField(dateInputElem, date); - await typeTextField(amountInputElem, amount.toString()); + await typeTextField(amountInputElem, amount?.toString()); + + if (targetType && targetType !== 'none') { + switch (targetType) { + case 'BANK_CARD': + await selectOption(targetTypeSelect, 'BANK_CARD'); + break; + case 'CASH': + await selectOption(targetTypeSelect, 'CASH'); + break; + default: + break; + } + + if (!bankCard && !cash) { + return; + } + const { bankCardSelect, cashSelect } = getFormElements(); + switch (targetType) { + case 'BANK_CARD': + await selectOption(bankCardSelect, bankCard); + break; + case 'CASH': + await selectOption(cashSelect, cash); + break; + default: + break; + } + } if (autoSubmit) { await submitForm(); diff --git a/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx b/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx index 42656e1..320e7d0 100644 --- a/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx +++ b/frontend/tests/helpers/income/item/DeleteIncomeItemButton.tsx @@ -12,10 +12,11 @@ const iconmeItem: IncomeListItem = { amount: 12, createdAt: '2025-08-29T07:57:40.503Z', currency: 'RUB', - date: '2025-08-22T00:00:03.000Z', + date: '2025-08-22T00:00:00.000Z', description: 'Mock description', id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6', incomeListId: '10cc093c-3875-4d33-a40a-df526266e262', + target: null, title: 'Mock item', updatedAt: '2025-08-29T07:57:40.503Z', }; @@ -43,9 +44,13 @@ const openModal = async () => { }; }; -const submitHandler = http.delete( +const submitHandler = (requestSpy: jest.Mock, status = 200) => http.delete( `/incomes/list/${iconmeItem.incomeListId}/items/${iconmeItem.id}`, - () => HttpResponse.json(iconmeItem), + () => { + requestSpy(iconmeItem); + + return HttpResponse.json(iconmeItem, { status }); + }, ); export const DeleteIncomeItemButtonHelper = { diff --git a/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx b/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx index 7e68047..069b9cc 100644 --- a/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx +++ b/frontend/tests/helpers/income/item/DeleteIncomeItemForm.tsx @@ -10,10 +10,11 @@ const incomeItem = { amount: 12, createdAt: '2025-08-29T07:57:40.503Z', currency: 'RUB', - date: '2025-08-22T00:00:03.000Z', + date: '2025-08-22T00:00:00.000Z', description: 'Mock description', id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6', incomeListId: '10cc093c-3875-4d33-a40a-df526266e262', + target: null, title: 'Mock item', updatedAt: '2025-08-29T07:57:40.503Z', }; @@ -23,7 +24,6 @@ const renderForm = (props?: Partial) => { render( , diff --git a/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx b/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx index d605caa..a358b09 100644 --- a/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx +++ b/frontend/tests/helpers/income/item/EditIncomeItemButton.tsx @@ -1,3 +1,5 @@ +import { getBankCardList, getCashList } from '@mocks/browser/handlers'; +import { server } from '@mocks/jest/server'; import { createAppWrapper } from '@mocks/jest/wrappers'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -17,15 +19,17 @@ const incomeItem: IncomeListItem = { amount: 12, createdAt: '2025-08-29T07:57:40.503Z', currency: 'RUB', - date: '2025-08-22T00:00:03.000Z', + date: '2025-08-22T00:00:00.000Z', description: 'Mock description', id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6', incomeListId: '10cc093c-3875-4d33-a40a-df526266e262', + target: null, title: 'Mock item', updatedAt: '2025-08-29T07:57:40.503Z', }; const renderButton = () => { + server.use(getBankCardList, getCashList); render( , { wrapper: createAppWrapper() }, @@ -48,7 +52,7 @@ const openModal = async () => { }; }; -const submitHandler = (requestSpy: jest.Mock) => http.patch< +const submitHandler = (requestSpy: jest.Mock, status = 200) => http.patch< EditIncomeItemPath, EditIncomeItemBody, EditIncomeItemSuccessResponse @@ -58,7 +62,7 @@ const submitHandler = (requestSpy: jest.Mock) => http.patch< const body = await request.json(); requestSpy(body); - return HttpResponse.json(incomeItem); + return HttpResponse.json(incomeItem, { status }); }, ); diff --git a/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx b/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx index dfe6fb8..ceff0d9 100644 --- a/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx +++ b/frontend/tests/helpers/income/item/EditIncomeItemForm.tsx @@ -1,4 +1,6 @@ -import { typeDateField, typeTextField } from '@mocks/jest/helpers'; +import { getBankCardList, getCashList } from '@mocks/browser/handlers'; +import { selectOption, typeDateField, typeTextField } from '@mocks/jest/helpers'; +import { server } from '@mocks/jest/server'; import { createAppWrapper } from '@mocks/jest/wrappers'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -13,15 +15,17 @@ const incomeItem = { count: 12, createdAt: '2025-08-29T07:57:40.503Z', currency: 'RUB', - date: '2025-08-22T00:00:03.000Z', + date: '2025-08-22T00:00:00.000Z', description: 'Mock description', id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6', incomeListId: '10cc093c-3875-4d33-a40a-df526266e262', + target: null, title: 'Mock item', updatedAt: '2025-08-29T07:57:40.503Z', }; const renderForm = (props?: Partial) => { + server.use(getBankCardList, getCashList); const onSubmit = jest.fn(); render( @@ -45,13 +49,25 @@ const getFormElements = () => { const dateInputElem = screen.getAllByLabelText(/Дата/i)[1]; const amountInputElem = screen.getByLabelText(/Сумма/i); const submitButtonElem = screen.getByTestId(DataTestId.EDIT_INCOME_ITEM_SUBMIT_BUTTON); + const targetTypeSelect = screen.getByTestId('target-type-select'); + + const bankCardSelect = screen.queryByTestId('bank-card-select'); + const bankCardSelectInput = screen.queryByTestId('bank-card-select-input'); + + const cashSelect = screen.queryByTestId('cash-select'); + const cashSelectInput = screen.queryByTestId('cash-select-input'); return { amountInputElem, + bankCardSelect, + bankCardSelectInput, + cashSelect, + cashSelectInput, dateInputElem, descriptionInputElem, formElem, submitButtonElem, + targetTypeSelect, titleInputElem, }; }; @@ -62,13 +78,16 @@ const submitForm = async () => { }; const fillForm = async ( - data: Omit, + data: Partial & { date: string }>, options: { autoSubmit?: boolean } = {}, ) => { const { amount, + 'bank-card': bankCard, + cash, date, description, + targetType, title, } = data; const { autoSubmit = true } = options; @@ -76,13 +95,42 @@ const fillForm = async ( amountInputElem, dateInputElem, descriptionInputElem, + targetTypeSelect, titleInputElem, } = getFormElements(); await typeTextField(titleInputElem, title); await typeTextField(descriptionInputElem, description); await typeDateField(dateInputElem, date); - await typeTextField(amountInputElem, amount.toString()); + await typeTextField(amountInputElem, amount?.toString()); + + if (targetType && targetType !== 'none') { + switch (targetType) { + case 'BANK_CARD': + await selectOption(targetTypeSelect, 'BANK_CARD'); + break; + case 'CASH': + await selectOption(targetTypeSelect, 'CASH'); + break; + default: + break; + } + + if (!bankCard && !cash) { + return; + } + const { bankCardSelect, cashSelect } = getFormElements(); + switch (targetType) { + case 'BANK_CARD': + await selectOption(bankCardSelect, bankCard); + break; + case 'CASH': + await selectOption(cashSelect, cash); + break; + default: + break; + } + } if (autoSubmit) { await submitForm();