feat(frontend): add income target, form validation, request notification

This commit is contained in:
Sergey Krylov 2025-10-15 08:01:06 +03:00
parent 5a5c308e45
commit eda67581ab
62 changed files with 1580 additions and 650 deletions

View File

@ -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 };

View File

@ -1,3 +1,4 @@
export { mockEnqueueSnackbar } from './enqueueSnackbar';
export { selectOption } from './selectOption';
export { typeDateField } from './typeDateField';
export { typeTextField } from './typeTextField';

View File

@ -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('Не найден элемент выпадающего списка');
}

View File

@ -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 (
<ReactQueryWrapper>
<LocalizationWrapper>
<SnackbarProvider />
{children}
</LocalizationWrapper>
</ReactQueryWrapper>

View File

@ -1 +0,0 @@
process.env.TZ = 'Europe/Moscow';

View File

@ -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();
});

View File

@ -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',

View File

@ -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"
}
}
}
}

View File

@ -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",

View File

@ -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 = () => (
<QueryClientProvider client={queryClient}>
<CssBaseline />
<SnackbarProvider anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }} />
<AuthContextProvider>
<LocalizationProvider adapterLocale={ru} dateAdapter={AdapterDateFns}>
<Routing />

View File

@ -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;
}

View File

@ -0,0 +1,2 @@
export type { AddExpenseItemFormProps, AddExpenseItemFormValues } from './AddExpenseItemForm.ui';
export { default as AddExpenseItemForm } from './AddExpenseItemForm.ui';

View File

@ -41,7 +41,6 @@ const EditExpenseItemForm: FC<EditExpenseItemFormProps> = (props) => {
...formProps
} = props;
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();

View File

@ -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';

View File

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

View File

@ -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<typeof AddIncomeItemFormSchema>;

View File

@ -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: 'Название элемента',
});
});
});
});

View File

@ -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> | void;
export type AddIncomeItemFormProps = BaseFormProps & UseAddIncomeItemFormArgs & {
ref?: RefObject<HTMLFormElement>;
};
export type AddIncomeItemFormValues = {
amount: number;
currency: string;
date: string;
description: string;
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const AddIncomeItemForm: FC<AddIncomeItemFormProps> = (props) => {
@ -37,82 +35,53 @@ const AddIncomeItemForm: FC<AddIncomeItemFormProps> = (props) => {
...formProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
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 (
<form
className={classes.form}
data-testid={DataTestId.ADD_INCOME_ITEM_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...formProps}
>
<Typography variant="h6">
Добавить список доходов
</Typography>
<TextField
disabled={disabled}
label="Название"
name="title"
required
size="small"
/>
<TextField
disabled={disabled}
label="Описание"
name="description"
size="small"
/>
<DatePicker
disabled={disabled}
disableFuture
format="dd.MM.yyyy"
label="Дата"
name="date"
slotProps={{ textField: { required: true, size: 'small' } }}
/>
<TextField
disabled={disabled}
inputMode="numeric"
label="Сумма"
name="amount"
required
size="small"
type="number"
/>
<Button
data-testid={DataTestId.ADD_INCOME_ITEM_SUBMIT_BUTTON}
disabled={disabled}
type="submit"
variant="contained"
<FormProvider {...methods}>
<form
className={classes.form}
data-testid={DataTestId.ADD_INCOME_ITEM_FORM}
noValidate
onSubmit={handleSubmit(onFormSubmitHandler)}
ref={ref}
{...formProps}
>
Добавить
</Button>
</form>
<Typography variant="h6">
Добавить элемент дохода
</Typography>
<TitleField />
<DescriptionField />
<DateField />
<AmountField />
<TargetTypeField />
{watch('targetType') === 'BANK_CARD' && <BankCardField />}
{watch('targetType') === 'CASH' && <CashField />}
<Button
data-testid={DataTestId.ADD_INCOME_ITEM_SUBMIT_BUTTON}
disabled={formState.disabled || formState.isSubmitting}
loading={formState.isSubmitting}
type="submit"
variant="contained"
>
Добавить
</Button>
</form>
</FormProvider>
);
};

View File

@ -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<Pick<AddIncomeItemFormValues, 'amount'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="amount"
render={({ field }) => (
<TextField
{...field}
error={Boolean(formState.errors.amount)}
helperText={formState.errors.amount?.message}
inputMode="numeric"
label="Сумма"
onChange={(event) => {
field.onChange(Number(event.target.value));
}}
required
size="small"
type="number"
/>
)}
/>
);
};
export default AmountField;

View File

@ -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<Pick<AddIncomeItemFormValues, 'bank-card'>>();
const { data: bankCards } = useBankCards();
const bankCardOptions = bankCards?.map((card) => (
<MenuItem key={card.id} value={card.id}>
{`${card.name} (${formatRub(card.balance)})`}
</MenuItem>
));
return (
<Controller
control={control}
defaultValue={bankCards?.[0].id}
disabled={formState.disabled}
name="bank-card"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors['bank-card'])}
fullWidth
>
<InputLabel id="bank-card-label">Карта</InputLabel>
<Select
{...field}
data-testid="bank-card-select"
inputProps={{ 'data-testid': 'bank-card-select-input' }}
labelId="bank-card-label"
size="small"
>
{bankCardOptions}
</Select>
<FormHelperText>
{formState.errors['bank-card']?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default BankCardField;

View File

@ -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<Pick<AddIncomeItemFormValues, 'cash'>>();
const { data: cash } = useCash();
const cashOptions = cash?.map((cashItem) => (
<MenuItem key={cashItem.id} value={cashItem.id}>
{`${cashItem.name} (${formatRub(cashItem.balance)})`}
</MenuItem>
));
return (
<Controller
control={control}
defaultValue={cash?.[0].id}
disabled={formState.disabled}
name="cash"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors.cash)}
fullWidth
>
<InputLabel id="cash-label">Наличные</InputLabel>
<Select
{...field}
data-testid="cash-select"
inputProps={{ 'data-testid': 'cash-select-input' }}
labelId="cash-label"
size="small"
>
{cashOptions}
</Select>
<FormHelperText>
{formState.errors.cash?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default CashField;

View File

@ -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<Pick<AddIncomeItemFormValues, 'date'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="date"
render={({ field }) => (
<DatePicker
{...field}
disableFuture
format="dd.MM.yyyy"
label="Дата"
slotProps={{
textField: {
error: Boolean(formState.errors.date),
helperText: formState.errors.date?.message,
required: true,
size: 'small',
},
}}
timezone="UTC"
/>
)}
/>
);
};
export default DateField;

View File

@ -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<Pick<AddIncomeItemFormValues, 'description'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="description"
render={({ field }) => (
<TextField
{...field}
error={Boolean(formState.errors.description)}
helperText={formState.errors.description?.message}
label="Описание"
size="small"
/>
)}
/>
);
};
export default DescriptionField;

View File

@ -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<Pick<AddIncomeItemFormValues, 'targetType'>>();
const { data: bankCards } = useBankCards();
const { data: cash } = useCash();
const targetTypeOptions = useMemo(() => {
const options = [<MenuItem key="none" value="none">Без оплаты</MenuItem>];
if (bankCards && bankCards.length > 0) {
options.push(<MenuItem key="BANK_CARD" value="BANK_CARD">Банковская карта</MenuItem>);
}
if (cash && cash.length > 0) {
options.push(<MenuItem key="CASH" value="CASH">Наличные</MenuItem>);
}
return options;
}, [bankCards, cash]);
return (
<Controller
control={control}
disabled={formState.disabled}
name="targetType"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors.targetType)}
fullWidth
>
<InputLabel id="target-type-label">Способ получения</InputLabel>
<Select
{...field}
data-testid="target-type-select"
inputProps={{ 'data-testid': 'target-type-select-input' }}
labelId="target-type-label"
size="small"
>
{targetTypeOptions}
</Select>
<FormHelperText>
{formState.errors.targetType?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default TargetTypeField;

View File

@ -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<Pick<AddIncomeItemFormValues, 'title'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="title"
render={({ field }) => (
<TextField
{...field}
error={Boolean(formState.errors.title)}
helperText={formState.errors.title?.message}
label="Название"
required
size="small"
/>
)}
/>
);
};
export default TitleField;

View File

@ -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> | void;
};
export const useAddIncomeItemForm = (args: UseAddIncomeItemFormArgs) => {
const { disabled, onSubmit } = args;
const methods = useForm<AddIncomeItemFormValues>({
defaultValues: {
amount: 0,
date: new Date(),
description: '',
targetType: 'none',
title: '',
},
disabled,
resolver: zodResolver(AddIncomeItemFormSchema),
shouldUnregister: true,
});
const {
formState,
handleSubmit,
watch,
} = methods;
const onFormSubmitHandler: SubmitHandler<AddIncomeItemFormValues> = async (data) => {
await onSubmit(data);
};
return {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
};
};

View File

@ -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';

View File

@ -13,6 +13,6 @@ describe('Test DeleteIncomeItemForm', () => {
await DeleteIncomeItemFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(DeleteIncomeItemFormHelper.incomeItem);
expect(onSubmit).toHaveBeenCalledWith();
});
});

View File

@ -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> | void;
import { useDeleteIncomeItemForm } from './hooks/useDeleteIncomeItemForm';
export type DeleteIncomeItemFormProps = BaseFormProps & UseDeleteIncomeItemFormArgs & {
ref?: RefObject<HTMLFormElement>;
};
@ -23,20 +21,17 @@ type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const DeleteIncomeItemForm: FC<DeleteIncomeItemFormProps> = (props) => {
const {
disabled,
item,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(item);
};
const { formState, handleSubmit, onFormSubmitHandler } = useDeleteIncomeItemForm({ disabled, onSubmit });
return (
<form
data-testid={DataTestId.DELETE_INCOME_ITEM_FORM}
onSubmit={onFormSubmitHandler}
onSubmit={handleSubmit(onFormSubmitHandler)}
ref={ref}
{...restProps}
>
@ -47,7 +42,8 @@ const DeleteIncomeItemForm: FC<DeleteIncomeItemFormProps> = (props) => {
<Button
color="error"
data-testid={DataTestId.DELETE_INCOME_ITEM_SUBMIT_BUTTON}
disabled={disabled}
disabled={formState.disabled || formState.isSubmitting}
loading={formState.isSubmitting}
type="submit"
variant="contained"
>

View File

@ -0,0 +1,28 @@
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
export type UseDeleteIncomeItemFormArgs = {
disabled?: boolean;
onSubmit: () => Promise<void> | void;
};
export const useDeleteIncomeItemForm = (args: UseDeleteIncomeItemFormArgs) => {
const { disabled, onSubmit } = args;
const onFormSubmitHandler: SubmitHandler<never> = async () => {
await onSubmit();
};
const methods = useForm<never>({
disabled,
shouldUnregister: true,
});
const { formState, handleSubmit } = methods;
return {
formState,
handleSubmit,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1,2 @@
export type { DeleteIncomeItemFormProps } from './DeleteIncomeItemForm.ui';
export { default as DeleteIncomeItemForm } from './DeleteIncomeItemForm.ui';

View File

@ -0,0 +1,13 @@
import * as z from 'zod';
export const EditIncomeItemFormSchema = 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 EditIncomeItemFormValues = z.infer<typeof EditIncomeItemFormSchema>;

View File

@ -1,3 +1,4 @@
import { bankCardList, cashList } from '@mocks/browser/data';
import { EditIncomeItemFormHelper } from '@tests/helpers';
describe('Test EditIncomeItemForm', () => {
@ -27,27 +28,95 @@ describe('Test EditIncomeItemForm', () => {
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
EditIncomeItemFormHelper.renderForm(options);
const { formElem, ...formElems } = EditIncomeItemFormHelper.getFormElements();
Object.values(formElems).forEach(assertion);
const elems = EditIncomeItemFormHelper.getFormElements();
Object.values([
elems.amountInputElem,
elems.dateInputElem,
elems.descriptionInputElem,
elems.submitButtonElem,
elems.titleInputElem,
]).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const { onSubmit } = EditIncomeItemFormHelper.renderForm();
describe('should call onSubmit with values', () => {
const setup = async () => {
const { onSubmit } = EditIncomeItemFormHelper.renderForm();
await EditIncomeItemFormHelper.fillForm({
amount: 12,
date: '29.08.2025',
description: 'Описание элемента',
title: 'Название элемента',
await EditIncomeItemFormHelper.fillForm(
{
amount: 12,
date: '29.08.2025',
description: 'Описание элемента',
targetType: 'none',
title: 'Название элемента',
},
{ autoSubmit: false },
);
return {
onSubmit,
};
};
test('no payment', async () => {
const { onSubmit } = await setup();
await EditIncomeItemFormHelper.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 EditIncomeItemFormHelper.fillForm({ targetType: 'BANK_CARD' }, { autoSubmit: false });
const { bankCardSelect, bankCardSelectInput } = EditIncomeItemFormHelper.getFormElements();
expect(bankCardSelect).toBeVisible();
expect(bankCardSelectInput).toBeEnabled();
expect(bankCardSelectInput).toHaveValue(bankCardList[0].id);
await EditIncomeItemFormHelper.fillForm({ 'bank-card': bankCardList[1].id, targetType: 'BANK_CARD' }, { autoSubmit: false });
expect(bankCardSelectInput).toHaveValue(bankCardList[1].id);
await EditIncomeItemFormHelper.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 EditIncomeItemFormHelper.fillForm({ targetType: 'CASH' });
const { cashSelect, cashSelectInput } = EditIncomeItemFormHelper.getFormElements();
expect(cashSelect).toBeVisible();
expect(cashSelectInput).toBeEnabled();
expect(cashSelectInput).toHaveValue(cashList[0].id);
await EditIncomeItemFormHelper.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: 'Название элемента',
});
});
});
});

View File

@ -1,34 +1,26 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import type { FC, 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 { IncomeListItem } from '../../types';
import type { UseEditIncomeItemFormArgs } from './hooks/useEditIncomeItemForm';
import classes from './EditIncomeItemForm.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 { useEditIncomeItemForm } from './hooks/useEditIncomeItemForm';
export type EditIncomeItemFormProps = BaseFormProps & {
disabled?: boolean;
item: IncomeListItem;
onSubmit?: (data: EditIncomeItemFormValues) => Promise<void> | void;
export type EditIncomeItemFormProps = BaseFormProps & UseEditIncomeItemFormArgs & {
ref?: RefObject<HTMLFormElement>;
};
export type EditIncomeItemFormValues = {
amount: number;
currency: string;
date: string;
description: string;
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const EditIncomeItemForm: FC<EditIncomeItemFormProps> = (props) => {
@ -40,83 +32,53 @@ const EditIncomeItemForm: FC<EditIncomeItemFormProps> = (props) => {
...formProps
} = props;
// eslint-disable-next-line @typescript-eslint/no-misused-promises
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string') {
await onSubmit?.({
amount: Number(amount),
currency: 'RUB',
date: getUTCDate(date),
description,
title,
});
}
};
const {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
} = useEditIncomeItemForm({ disabled, item, onSubmit });
return (
<form
className={classes.form}
data-testid={DataTestId.EDIT_INCOME_ITEM_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...formProps}
>
<Typography variant="h6">
Изменить доход
</Typography>
<TextField
defaultValue={item.title}
disabled={disabled}
label="Название"
name="title"
required
size="small"
/>
<TextField
defaultValue={item.description ?? ''}
disabled={disabled}
label="Описание"
name="description"
size="small"
/>
<DatePicker
defaultValue={new Date(item.date)}
disabled={disabled}
label="Дата"
name="date"
slotProps={{ textField: { required: true, size: 'small' } }}
/>
<TextField
defaultValue={item.amount}
disabled={disabled}
inputMode="numeric"
label="Сумма"
name="amount"
size="small"
type="number"
/>
<Button
data-testid={DataTestId.EDIT_INCOME_ITEM_SUBMIT_BUTTON}
disabled={disabled}
type="submit"
variant="contained"
<FormProvider {...methods}>
<form
className={classes.form}
data-testid={DataTestId.EDIT_INCOME_ITEM_FORM}
noValidate
onSubmit={handleSubmit(onFormSubmitHandler)}
ref={ref}
{...formProps}
>
Сохранить
</Button>
</form>
<Typography variant="h6">
Изменить доход
</Typography>
<TitleField />
<DescriptionField />
<DateField />
<AmountField />
<TargetTypeField />
{watch('targetType') === 'BANK_CARD' && <BankCardField />}
{watch('targetType') === 'CASH' && <CashField />}
<Button
data-testid={DataTestId.EDIT_INCOME_ITEM_SUBMIT_BUTTON}
disabled={disabled || formState.isSubmitting}
loading={formState.isSubmitting}
type="submit"
variant="contained"
>
Сохранить
</Button>
</form>
</FormProvider>
);
};

View File

@ -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<Pick<EditIncomeItemFormValues, 'amount'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="amount"
render={({ field }) => (
<TextField
{...field}
error={Boolean(formState.errors.amount)}
helperText={formState.errors.amount?.message}
inputMode="numeric"
label="Сумма"
onChange={(event) => {
field.onChange(Number(event.target.value));
}}
required
size="small"
type="number"
/>
)}
/>
);
};
export default AmountField;

View File

@ -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<Pick<EditIncomeItemFormValues, 'bank-card'>>();
const { data: bankCards } = useBankCards();
const bankCardOptions = bankCards?.map((card) => (
<MenuItem key={card.id} value={card.id}>
{`${card.name} (${formatRub(card.balance)})`}
</MenuItem>
));
return (
<Controller
control={control}
defaultValue={bankCards?.[0].id}
disabled={formState.disabled}
name="bank-card"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors['bank-card'])}
fullWidth
>
<InputLabel id="bank-card-label">Карта</InputLabel>
<Select
{...field}
data-testid="bank-card-select"
inputProps={{ 'data-testid': 'bank-card-select-input' }}
labelId="bank-card-label"
size="small"
>
{bankCardOptions}
</Select>
<FormHelperText>
{formState.errors['bank-card']?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default BankCardField;

View File

@ -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<Pick<EditIncomeItemFormValues, 'cash'>>();
const { data: cash } = useCash();
const cashOptions = cash?.map((cashItem) => (
<MenuItem key={cashItem.id} value={cashItem.id}>
{`${cashItem.name} (${formatRub(cashItem.balance)})`}
</MenuItem>
));
return (
<Controller
control={control}
defaultValue={cash?.[0].id}
disabled={formState.disabled}
name="cash"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors.cash)}
fullWidth
>
<InputLabel id="cash-label">Наличные</InputLabel>
<Select
{...field}
data-testid="cash-select"
inputProps={{ 'data-testid': 'cash-select-input' }}
labelId="cash-label"
size="small"
>
{cashOptions}
</Select>
<FormHelperText>
{formState.errors.cash?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default CashField;

View File

@ -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<Pick<EditIncomeItemFormValues, 'date'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="date"
render={({ field }) => (
<DatePicker
{...field}
disableFuture
format="dd.MM.yyyy"
label="Дата"
slotProps={{
textField: {
error: Boolean(formState.errors.date),
helperText: formState.errors.date?.message,
required: true,
size: 'small',
},
}}
timezone="UTC"
/>
)}
/>
);
};
export default DateField;

View File

@ -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<Pick<EditIncomeItemFormValues, 'description'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="description"
render={({ field }) => (
<TextField
{...field}
error={Boolean(formState.errors.description)}
helperText={formState.errors.description?.message}
label="Описание"
size="small"
/>
)}
/>
);
};
export default DescriptionField;

View File

@ -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<Pick<EditIncomeItemFormValues, 'targetType'>>();
const { data: bankCards } = useBankCards();
const { data: cash } = useCash();
const targetTypeOptions = useMemo(() => {
const options = [<MenuItem key="none" value="none">Без оплаты</MenuItem>];
if (bankCards && bankCards.length > 0) {
options.push(<MenuItem key="BANK_CARD" value="BANK_CARD">Банковская карта</MenuItem>);
}
if (cash && cash.length > 0) {
options.push(<MenuItem key="CASH" value="CASH">Наличные</MenuItem>);
}
return options;
}, [bankCards, cash]);
return (
<Controller
control={control}
disabled={formState.disabled}
name="targetType"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors.targetType)}
fullWidth
>
<InputLabel id="target-type-label">Способ получения</InputLabel>
<Select
{...field}
data-testid="target-type-select"
inputProps={{ 'data-testid': 'target-type-select-input' }}
labelId="target-type-label"
size="small"
>
{targetTypeOptions}
</Select>
<FormHelperText>
{formState.errors.targetType?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default TargetTypeField;

View File

@ -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<Pick<EditIncomeItemFormValues, 'title'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="title"
render={({ field }) => (
<TextField
{...field}
error={Boolean(formState.errors.title)}
helperText={formState.errors.title?.message}
label="Название"
required
size="small"
/>
)}
/>
);
};
export default TitleField;

View File

@ -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> | void;
};
export const useEditIncomeItemForm = (args: UseEditIncomeItemFormArgs) => {
const { disabled, item, onSubmit } = args;
const methods = useForm<EditIncomeItemFormValues>({
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<EditIncomeItemFormValues> = async (data) => {
await onSubmit(data);
};
return {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
};
};

View File

@ -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';

View File

@ -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 {

View File

@ -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' });
});
});
});

View File

@ -44,7 +44,7 @@ const AddIncomeItemButton: FC<AddIncomeItemButtonProps> = (props) => {
Добавить
</Button>
<Modal onClose={onCloseModalHandler} open={isOpen}>
<Modal onClose={onCloseModalHandler} open={isOpen || isPending}>
<AddIncomeItemForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>

View File

@ -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 {

View File

@ -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' });
});
});
});

View File

@ -44,7 +44,7 @@ const DeleteIncomeItemButton: FC<DeleteIncomeItemButtonProps> = (props) => {
</Button>
<Modal onClose={onCloseModalHandler} open={isOpen}>
<DeleteIncomeItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
<DeleteIncomeItemForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);

View File

@ -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 {

View File

@ -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' });
});
});
});

View File

@ -45,7 +45,7 @@ const EditIncomeItemButton: FC<EditIncomeItemButtonProps> = (props) => {
Редактировать
</Button>
<Modal onClose={onCloseModalHandler} open={isOpen}>
<Modal onClose={onCloseModalHandler} open={isOpen || isPending}>
<EditIncomeItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
</Modal>
</>

View File

@ -32,7 +32,12 @@ const IncomePage = () => {
return (
<AppLayout>
<Typography variant="h4">
Списки расходов
Элементы дохода
{' '}
&quot;
{incomeList?.title}
&quot;
</Typography>
<AddIncomeItemButton listId={id} sx={{ mb: 2, mt: 2 }} />

View File

@ -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: {
/**

View File

@ -6,3 +6,4 @@ export { formatRub } from './formatters/currency';
export { formatDate } from './formatters/date';
export { getUTCDate } from './helpers/getUTCDate';
export { setupZod } from './zod/config';

View File

@ -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: 'Ошибка',
};
},
});

View File

@ -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(
<AddIncomeItemButton listId="123" />,
{ 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 });
},
);

View File

@ -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<AddIncomeItemFormProps>) => {
server.use(getBankCardList, getCashList);
const onSubmit = jest.fn();
render(
<AddIncomeItemForm onSubmit={onSubmit} {...props} />,
{ 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<AddIncomeItemFormValues, 'currency'>,
data: Partial<Omit<AddIncomeItemFormValues, 'currency' | 'date'> & { 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();

View File

@ -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<DeleteIncomeItemPath>(
const submitHandler = (requestSpy: jest.Mock, status = 200) => http.delete<DeleteIncomeItemPath>(
`/incomes/list/${iconmeItem.incomeListId}/items/${iconmeItem.id}`,
() => HttpResponse.json(iconmeItem),
() => {
requestSpy(iconmeItem);
return HttpResponse.json(iconmeItem, { status });
},
);
export const DeleteIncomeItemButtonHelper = {

View File

@ -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<DeleteIncomeItemFormProps>) => {
render(
<DeleteIncomeItemForm
item={incomeItem}
onSubmit={onSubmit}
{...props}
/>,

View File

@ -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(
<EditIncomeItemButton item={incomeItem} />,
{ 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 });
},
);

View File

@ -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<EditIncomeItemFormProps>) => {
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<EditIncomeItemFormValues, 'currency'>,
data: Partial<Omit<EditIncomeItemFormValues, 'currency' | 'date'> & { 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();