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

This commit is contained in:
Sergey Krylov 2025-10-17 06:25:55 +03:00
parent f8d766beb0
commit af72e8dc92
46 changed files with 1237 additions and 443 deletions

View File

@ -8,11 +8,6 @@ export { useEditExpenseItem } from './hooks/useEditExpenseItem';
export type { ExpenseListItem } from './types';
export type { AddExpenseItemFormProps, AddExpenseItemFormValues } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
export { default as AddExpenseItemForm } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
export type { DeleteExpenseItemFormProps } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
export { default as DeleteExpenseItemForm } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
export type { EditExpenseItemFormProps, EditExpenseItemFormValues } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
export { default as EditExpenseItemForm } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
export * from './ui/AddExpenseItemForm';
export * from './ui/DeleteExpenseItemForm';
export * from './ui/EditExpenseItemForm';

View File

@ -0,0 +1,14 @@
import * as z from 'zod';
export const AddExpenseItemFormSchema = z.object({
amount: z.number(),
'bank-card': z.string().optional(),
cash: z.string().optional(),
count: z.number(),
date: z.date(),
description: z.string().trim().optional(),
paymentType: z.enum(['BANK_CARD', 'CASH', 'none']),
title: z.string().trim().min(5),
});
export type AddExpenseItemFormValues = z.infer<typeof AddExpenseItemFormSchema>;

View File

@ -55,10 +55,9 @@ describe('Test AddExpenseItemForm', () => {
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
date: new Date('2025-08-29T00:00:00.000Z'),
description: 'Описание элемента',
payment: null,
paymentType: 'none',
title: 'Название элемента',
});
});
@ -80,14 +79,11 @@ describe('Test AddExpenseItemForm', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
'bank-card': bankCardList[1].id,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
date: new Date('2025-08-29T00:00:00.000Z'),
description: 'Описание элемента',
payment: {
id: bankCardList[1].id,
type: 'BANK_CARD',
},
paymentType: 'BANK_CARD',
title: 'Название элемента',
});
});
@ -106,14 +102,11 @@ describe('Test AddExpenseItemForm', () => {
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
cash: cashList[1].id,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
date: new Date('2025-08-29T00:00:00.000Z'),
description: 'Описание элемента',
payment: {
id: cashList[1].id,
type: 'CASH',
},
paymentType: 'CASH',
title: 'Название элемента',
});
});

View File

@ -1,54 +1,27 @@
import type { SelectChangeEvent } from '@mui/material';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import type { FC, FormHTMLAttributes, RefObject } from 'react';
import {
Button,
FormControl,
InputLabel,
MenuItem,
Select,
TextField,
Typography,
} from '@mui/material';
import { DatePicker } from '@mui/x-date-pickers';
import {
useMemo,
useState,
} from 'react';
import { Button, Typography } from '@mui/material';
import { FormProvider } from 'react-hook-form';
import type { ExpenseListItem } from '@/entity/expenses/item';
import { useBankCards } from '@/entity/cards';
import { useCash } from '@/entity/cash';
import { DataTestId } from '@/shared/constants';
import { formatRub, getUTCDate } from '@/shared/utils';
import type { UseAddExpenseItemFormArgs } from './hooks/useAddExpenseItemForm';
import classes from './AddExpenseItemForm.module.css';
import AmountField from './fields/AmountField';
import BankCardField from './fields/BankCardField';
import CashField from './fields/CashField';
import CountField from './fields/CountField';
import DateField from './fields/DateField';
import DescriptionField from './fields/DescriptionField';
import PaymentTypeField from './fields/PaymentTypeField';
import TitleField from './fields/TitleField';
import { useAddExpenseItemForm } from './hooks/useAddExpenseItemForm';
export type AddExpenseItemFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddExpenseItemFormValues) => Promise<void> | void;
export type AddExpenseItemFormProps = BaseFormProps & UseAddExpenseItemFormArgs & {
ref?: RefObject<HTMLFormElement>;
};
export type AddExpenseItemFormValues = {
amount: number;
count: number;
currency: string;
date: string;
description: string;
payment: null | {
id: string;
type: 'BANK_CARD' | 'CASH';
};
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
@ -59,214 +32,55 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
...formProps
} = props;
const [paymentType, setPaymentType] = useState<NonNullable<ExpenseListItem['payment']>['type']>(null);
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
const paymentMethod = formData.get('paymentMethod');
const bankCardId = formData.get('bank-card');
const cashId = formData.get('cash');
const getPayment = () => {
switch (paymentMethod) {
case 'BANK_CARD':
return {
id: bankCardId,
type: 'BANK_CARD',
};
case 'CASH':
return {
id: cashId,
type: 'CASH',
};
case 'none':
case null:
default:
return null;
}
};
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
onSubmit?.({
amount: Number(amount),
count: Number(count),
currency: 'RUB',
date: getUTCDate(date),
description,
// @ts-ignore todo
payment: getPayment(),
title,
});
}
};
const { data: bankCards } = useBankCards();
const { data: cash } = useCash();
const bankCardOptions = bankCards?.map((card) => (
<MenuItem key={card.id} value={card.id}>
{`${card.name} (${formatRub(card.balance)})`}
</MenuItem>
));
const cashOptions = cash?.map((cashItem) => (
<MenuItem key={cashItem.id} value={cashItem.id}>
{`${cashItem.name} (${formatRub(cashItem.balance)})`}
</MenuItem>
));
const paymentOptions = useMemo(() => {
const options = [<MenuItem key="none" value="none">Без оплаты</MenuItem>];
if (bankCardOptions && bankCardOptions.length > 0) {
options.push(<MenuItem key="BANK_CARD" value="BANK_CARD">Банковская карта</MenuItem>);
}
if (cashOptions && cashOptions.length > 0) {
options.push(<MenuItem key="CASH" value="CASH">Наличные</MenuItem>);
}
return options;
}, [bankCardOptions, cashOptions]);
const onChangePaymentHandler = (event: SelectChangeEvent<'BANK_CARD' | 'CASH' | 'none'>) => {
const { value } = event.target;
if (value === 'none') {
setPaymentType(null);
} else {
// @ts-ignore todo
setPaymentType(value);
}
};
const renderCardSelect = () => (
<FormControl fullWidth>
<InputLabel id="bank-card-label">Карта</InputLabel>
<Select
data-testid="bank-card-select"
defaultValue={bankCards?.[0].id}
disabled={disabled}
inputProps={{ 'data-testid': 'bank-card-select-input' }}
labelId="bank-card-label"
name="bank-card"
size="small"
>
{bankCardOptions}
</Select>
</FormControl>
);
const renderCashSelect = () => (
<FormControl fullWidth>
<InputLabel id="cash-label">Наличные</InputLabel>
<Select
data-testid="cash-select"
defaultValue={cash?.[0].id}
disabled={disabled}
inputProps={{ 'data-testid': 'cash-select-input' }}
labelId="cash-label"
name="cash"
size="small"
>
{cashOptions ?? []}
</Select>
</FormControl>
);
const {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
} = useAddExpenseItemForm({ disabled, onSubmit });
return (
<FormProvider {...methods}>
<form
className={classes.form}
data-testid={DataTestId.ADD_EXPENSE_ITEM_FORM}
onSubmit={onFormSubmitHandler}
noValidate
onSubmit={handleSubmit(onFormSubmitHandler)}
ref={ref}
{...formProps}
>
<Typography variant="h6">
Добавить список расходов
Добавить элемент расхода
</Typography>
<TextField
disabled={disabled}
label="Название"
name="title"
required
size="small"
/>
<TitleField />
<TextField
disabled={disabled}
label="Описание"
name="description"
size="small"
/>
<DescriptionField />
<DatePicker
defaultValue={new Date()}
disabled={disabled}
label="Дата"
name="date"
slotProps={{ textField: { required: true, size: 'small' } }}
/>
<DateField />
<TextField
disabled={disabled}
inputMode="numeric"
label="Сумма"
name="amount"
required
size="small"
type="number"
/>
<AmountField />
<TextField
defaultValue={1}
disabled={disabled}
inputMode="numeric"
label="Количество"
name="count"
size="small"
type="number"
/>
<CountField />
<FormControl fullWidth>
<InputLabel id="payment-type-label">Способ оплаты</InputLabel>
<PaymentTypeField />
<Select
data-testid="payment-type-select"
defaultValue="none"
disabled={disabled}
inputProps={{ 'data-testid': 'payment-type-select-input' }}
labelId="payment-type-label"
name="paymentMethod"
onChange={onChangePaymentHandler}
size="small"
>
{paymentOptions}
</Select>
</FormControl>
{watch('paymentType') === 'BANK_CARD' && <BankCardField />}
{paymentType === 'BANK_CARD' && renderCardSelect()}
{paymentType === 'CASH' && renderCashSelect()}
{watch('paymentType') === 'CASH' && <CashField />}
<Button
data-testid={DataTestId.ADD_EXPENSE_ITEM_SUBMIT_BUTTON}
disabled={disabled}
size="small"
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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const AmountField = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, '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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const BankCardField = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, '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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const CashField = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, '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,33 @@
import { TextField } from '@mui/material';
import { Controller, useFormContext } from 'react-hook-form';
import type { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const CountField = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, 'count'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="count"
render={({ field }) => (
<TextField
{...field}
defaultValue={1}
error={Boolean(formState.errors.count)}
helperText={formState.errors.count?.message}
inputMode="numeric"
label="Количество"
onChange={(event) => {
field.onChange(Number(event.target.value));
}}
size="small"
type="number"
/>
)}
/>
);
};
export default CountField;

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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const DateField: FC = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, '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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const DescriptionField: FC = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, '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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const PaymentTypeField: FC = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, 'paymentType'>>();
const { data: bankCards } = useBankCards();
const { data: cash } = useCash();
const paymentTypeOptions = 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="paymentType"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors.paymentType)}
fullWidth
>
<InputLabel id="payment-type-label">Способ оплаты</InputLabel>
<Select
{...field}
data-testid="payment-type-select"
inputProps={{ 'data-testid': 'payment-type-select-input' }}
labelId="payment-type-label"
size="small"
>
{paymentTypeOptions}
</Select>
<FormHelperText>
{formState.errors.paymentType?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default PaymentTypeField;

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 { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
const TitleField: FC = () => {
const { control, formState } = useFormContext<Pick<AddExpenseItemFormValues, '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,48 @@
import type { SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { AddExpenseItemFormValues } from '../AddExpenseItemForm.schema';
import { AddExpenseItemFormSchema } from '../AddExpenseItemForm.schema';
export type UseAddExpenseItemFormArgs = {
disabled?: boolean;
onSubmit: (data: AddExpenseItemFormValues) => Promise<void> | void;
};
export const useAddExpenseItemForm = (args: UseAddExpenseItemFormArgs) => {
const { disabled, onSubmit } = args;
const methods = useForm<AddExpenseItemFormValues>({
defaultValues: {
amount: 0,
count: 1,
date: new Date(Date.now()),
description: '',
paymentType: 'none',
title: '',
},
disabled,
resolver: zodResolver(AddExpenseItemFormSchema),
shouldUnregister: true,
});
const {
formState,
handleSubmit,
watch,
} = methods;
const onFormSubmitHandler: SubmitHandler<AddExpenseItemFormValues> = async (data) => {
await onSubmit(data);
};
return {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
};
};

View File

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

View File

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

View File

@ -1,20 +1,14 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import type { FC, FormHTMLAttributes, RefObject } from 'react';
import { Button, Typography } from '@mui/material';
import { DataTestId } from '@/shared/constants';
import type { ExpenseListItem } from '../../types';
import type { UseDeleteExpenseItemFormArgs } from './hooks/useDeleteExpenseItemForm';
export type DeleteExpenseItemFormProps = BaseFormProps & {
disabled?: boolean;
item: ExpenseListItem;
onSubmit?: (item: ExpenseListItem) => Promise<void> | void;
import { useDeleteExpenseItemForm } from './hooks/useDeleteExpenseItemForm';
export type DeleteExpenseItemFormProps = BaseFormProps & UseDeleteExpenseItemFormArgs & {
ref?: RefObject<HTMLFormElement>;
};
@ -23,20 +17,17 @@ type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const DeleteExpenseItemForm: FC<DeleteExpenseItemFormProps> = (props) => {
const {
disabled,
item,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(item);
};
const { formState, handleSubmit, onFormSubmitHandler } = useDeleteExpenseItemForm({ disabled, onSubmit });
return (
<form
data-testid={DataTestId.DELETE_EXPENSE_ITEM_FORM}
onSubmit={onFormSubmitHandler}
onSubmit={handleSubmit(onFormSubmitHandler)}
ref={ref}
{...restProps}
>
@ -47,9 +38,8 @@ const DeleteExpenseItemForm: FC<DeleteExpenseItemFormProps> = (props) => {
<Button
color="error"
data-testid={DataTestId.DELETE_EXPENSE_ITEM_SUBMIT_BUTTON}
disabled={disabled}
fullWidth
sx={{ mt: 2 }}
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 UseDeleteExpenseItemFormArgs = {
disabled?: boolean;
onSubmit: () => Promise<void> | void;
};
export const useDeleteExpenseItemForm = (args: UseDeleteExpenseItemFormArgs) => {
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 { DeleteExpenseItemFormProps } from './DeleteExpenseItemForm.ui';
export { default as DeleteExpenseItemForm } from './DeleteExpenseItemForm.ui';

View File

@ -0,0 +1,14 @@
import * as z from 'zod';
export const EditExpenseItemFormSchema = z.object({
amount: z.number(),
'bank-card': z.string().optional(),
cash: z.string().optional(),
count: z.number(),
date: z.date(),
description: z.string().trim().optional(),
paymentType: z.enum(['BANK_CARD', 'CASH', 'none']),
title: z.string().trim().min(5),
});
export type EditExpenseItemFormValues = z.infer<typeof EditExpenseItemFormSchema>;

View File

@ -20,7 +20,7 @@ describe('Test EditExpenseItemForm', () => {
expect(titleInputElem).toHaveValue('Mock item');
expect(descriptionInputElem).toHaveValue('Mock description');
expect(dateInputElem).toHaveValue('22.08.2025');
expect(amountInputElem).toHaveValue('12');
expect(amountInputElem).toHaveValue(12);
expect(countInputElem).toHaveValue(12);
});
@ -29,8 +29,14 @@ describe('Test EditExpenseItemForm', () => {
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
EditExpenseItemFormHelper.renderForm(options);
const { formElem, ...formElems } = EditExpenseItemFormHelper.getFormElements();
Object.values(formElems).forEach(assertion);
const elems = EditExpenseItemFormHelper.getFormElements();
Object.values([
elems.amountInputElem,
elems.dateInputElem,
elems.descriptionInputElem,
elems.submitButtonElem,
elems.titleInputElem,
]).forEach(assertion);
});
test('should call onSubmit with values', async () => {
@ -41,6 +47,7 @@ describe('Test EditExpenseItemForm', () => {
count: 1,
date: '29.08.2025',
description: 'Описание элемента',
paymentType: 'none',
title: 'Название элемента',
});
@ -48,9 +55,9 @@ describe('Test EditExpenseItemForm', () => {
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
date: new Date('2025-08-29T00:00:00.000Z'),
description: 'Описание элемента',
paymentType: 'none',
title: 'Название элемента',
});
});

View File

@ -1,35 +1,31 @@
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 { ExpenseListItem } from '../../types';
import type { UseEditExpenseItemFormArgs } from './hooks/useEditExpenseItemForm';
import classes from './EditExpenseItemForm.module.css';
import AmountField from './fields/AmountField';
import BankCardField from './fields/BankCardField';
import CashField from './fields/CashField';
import CountField from './fields/CountField';
import DateField from './fields/DateField';
import DescriptionField from './fields/DescriptionField';
import PaymentTypeField from './fields/PaymentTypeField';
import TitleField from './fields/TitleField';
import { useEditExpenseItemForm } from './hooks/useEditExpenseItemForm';
export type EditExpenseItemFormProps = BaseFormProps & {
disabled?: boolean;
item: ExpenseListItem;
onSubmit?: (data: EditExpenseItemFormValues) => Promise<void> | void;
export type EditExpenseItemFormProps = BaseFormProps & UseEditExpenseItemFormArgs & {
ref?: RefObject<HTMLFormElement>;
};
export type EditExpenseItemFormValues = {
amount: number;
count: number;
currency: string;
date: string;
description: string;
title: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const EditExpenseItemForm: FC<EditExpenseItemFormProps> = (props) => {
@ -41,93 +37,55 @@ const EditExpenseItemForm: FC<EditExpenseItemFormProps> = (props) => {
...formProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = async (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const title = formData.get('title');
const date = formData.get('date');
const amount = formData.get('amount');
const description = formData.get('description');
const count = formData.get('count');
if (typeof title === 'string' && typeof date === 'string' && typeof amount === 'string' && typeof description === 'string' && typeof count === 'string') {
await onSubmit?.({
amount: Number(amount),
count: Number(count),
currency: 'RUB',
date: getUTCDate(date),
description,
title,
});
}
};
const {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
} = useEditExpenseItemForm({ disabled, item, onSubmit });
return (
<FormProvider {...methods}>
<form
className={classes.form}
data-testid={DataTestId.EDIT_EXPENSE_ITEM_FORM}
onSubmit={onFormSubmitHandler}
noValidate
onSubmit={handleSubmit(onFormSubmitHandler)}
ref={ref}
{...formProps}
>
<Typography variant="h6">
Добавить список расходов
Редактировать элемент расхода
</Typography>
<TextField
defaultValue={item.title}
disabled={disabled}
label="Название"
name="title"
required
size="small"
/>
<TitleField />
<TextField
defaultValue={item.description ?? ''}
disabled={disabled}
label="Описание"
name="description"
size="small"
/>
<DescriptionField />
<DatePicker
defaultValue={new Date(item.date)}
disabled={disabled}
label="Дата"
name="date"
slotProps={{ textField: { required: true, size: 'small' } }}
/>
<DateField />
<TextField
defaultValue={item.amount}
disabled={disabled}
inputMode="numeric"
label="Сумма"
name="amount"
required
size="small"
/>
<AmountField />
<TextField
defaultValue={item.count ?? 1}
disabled={disabled}
label="Количество"
name="count"
size="small"
type="number"
/>
<CountField />
<PaymentTypeField />
{watch('paymentType') === 'BANK_CARD' && <BankCardField />}
{watch('paymentType') === 'CASH' && <CashField />}
<Button
data-testid={DataTestId.EDIT_EXPENSE_ITEM_SUBMIT_BUTTON}
disabled={disabled}
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 { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const AmountField = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, '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 { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const BankCardField = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, '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 { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const CashField = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, '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,33 @@
import { TextField } from '@mui/material';
import { Controller, useFormContext } from 'react-hook-form';
import type { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const CountField = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, 'count'>>();
return (
<Controller
control={control}
disabled={formState.disabled}
name="count"
render={({ field }) => (
<TextField
{...field}
defaultValue={1}
error={Boolean(formState.errors.count)}
helperText={formState.errors.count?.message}
inputMode="numeric"
label="Количество"
onChange={(event) => {
field.onChange(Number(event.target.value));
}}
size="small"
type="number"
/>
)}
/>
);
};
export default CountField;

View File

@ -0,0 +1,36 @@
import type { FC } from 'react';
import { DatePicker } from '@mui/x-date-pickers';
import { Controller, useFormContext } from 'react-hook-form';
import type { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const DateField: FC = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, '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',
},
}}
/>
)}
/>
);
};
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 { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const DescriptionField: FC = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, '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 { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const PaymentTypeField: FC = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, 'paymentType'>>();
const { data: bankCards } = useBankCards();
const { data: cash } = useCash();
const paymentTypeOptions = 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="paymentType"
render={({ field }) => (
<FormControl
error={Boolean(formState.errors.paymentType)}
fullWidth
>
<InputLabel id="payment-type-label">Способ оплаты</InputLabel>
<Select
{...field}
data-testid="payment-type-select"
inputProps={{ 'data-testid': 'payment-type-select-input' }}
labelId="payment-type-label"
size="small"
>
{paymentTypeOptions}
</Select>
<FormHelperText>
{formState.errors.paymentType?.message}
</FormHelperText>
</FormControl>
)}
/>
);
};
export default PaymentTypeField;

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 { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
const TitleField: FC = () => {
const { control, formState } = useFormContext<Pick<EditExpenseItemFormValues, '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,55 @@
import type { SubmitHandler } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import type { ExpenseListItem } from '@/entity/expenses/item';
import type { EditExpenseItemFormValues } from '../EditExpenseItemForm.schema';
import { EditExpenseItemFormSchema } from '../EditExpenseItemForm.schema';
export type UseEditExpenseItemFormArgs = {
disabled?: boolean;
item: ExpenseListItem;
onSubmit: (data: EditExpenseItemFormValues) => Promise<void> | void;
};
export const useEditExpenseItemForm = (args: UseEditExpenseItemFormArgs) => {
const { disabled, item, onSubmit } = args;
const methods = useForm<EditExpenseItemFormValues>({
defaultValues: {
amount: item.amount,
// eslint-disable-next-line no-undefined
'bank-card': item.payment?.type === 'BANK_CARD' ? item.payment.id ?? undefined : undefined,
// eslint-disable-next-line no-undefined
cash: item.payment?.type === 'CASH' ? item.payment.id ?? undefined : undefined,
count: item.count ?? 1,
date: new Date(item.date),
description: item.description ?? '',
paymentType: item.payment?.type ?? 'none',
title: item.title,
},
disabled,
resolver: zodResolver(EditExpenseItemFormSchema),
shouldUnregister: true,
});
const {
formState,
handleSubmit,
watch,
} = methods;
const onFormSubmitHandler: SubmitHandler<EditExpenseItemFormValues> = async (data) => {
await onSubmit(data);
};
return {
formState,
handleSubmit,
methods,
onFormSubmitHandler,
watch,
};
};

View File

@ -0,0 +1,4 @@
export type { EditExpenseItemFormValues } from './EditExpenseItemForm.schema';
export { EditExpenseItemFormSchema } from './EditExpenseItemForm.schema';
export type { EditExpenseItemFormProps } from './EditExpenseItemForm.ui';
export { default as EditExpenseItemForm } from './EditExpenseItemForm.ui';

View File

@ -1,5 +1,6 @@
import type { ButtonHTMLAttributes, MouseEventHandler } from 'react';
import { enqueueSnackbar } from 'notistack';
import { useState } from 'react';
import type { AddExpenseItemFormValues } from '@/entity/expenses/item';
@ -26,10 +27,30 @@ export const useAddExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
};
const onFormSubmitHandler = async (data: AddExpenseItemFormValues) => {
// todo добавить обработку ошибок (код + тест)
await addExpenseItemMutation(data);
try {
const payment = data.paymentType === 'none'
? null
: {
id: data.paymentType === 'BANK_CARD' ? data['bank-card'] : data.cash,
type: data.paymentType,
};
await addExpenseItemMutation({
amount: data.amount,
count: data.count,
currency: 'RUB',
date: data.date.toISOString(),
description: data.description ?? null,
// @ts-ignore TODO
payment,
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 { AddExpenseItemButtonHelper, AddExpenseItemFormHelper } from '@tests/helpers';
@ -22,7 +23,7 @@ describe('Test AddExpenseItemButton', () => {
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();
@ -36,11 +37,10 @@ describe('Test AddExpenseItemButton', () => {
date: '29.08.2025',
description: 'Описание элемента',
title: 'Название элемента',
}, { autoSubmit: false });
});
});
test('should send correct request', async () => {
await AddExpenseItemFormHelper.submitForm();
test('should send correct request', () => {
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
amount: 12,
@ -54,10 +54,41 @@ describe('Test AddExpenseItemButton', () => {
});
test('should close modal after submit', async () => {
await AddExpenseItemFormHelper.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(AddExpenseItemButtonHelper.submitHandler(requestSpy, 500));
modalElem = (await AddExpenseItemButtonHelper.openModal()).modalElem;
await AddExpenseItemFormHelper.fillForm({
amount: '12',
count: '1',
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 AddExpenseItemButton: FC<AddExpenseItemButtonProps> = (props) => {
Добавить элемент
</Button>
<Modal onClose={onCloseModalHandler} open={isOpen}>
<Modal onClose={onCloseModalHandler} open={isOpen || isPending}>
<AddExpenseItemForm 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 ExpenseListItem, useDeleteExpenseItem } from '@/entity/expenses/item';
@ -27,8 +28,14 @@ export const useDeleteExpenseItemButton = (args: UseDeleteExpenseItemButtonArgs)
});
const onFormSubmitHandler = async () => {
try {
await deleteExpenseItemMutation();
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 { DeleteExpenseItemButtonHelper, DeleteExpenseItemFormHelper } from '@tests/helpers';
describe('Test DeleteExpenseItemButton', () => {
@ -23,15 +23,51 @@ describe('Test DeleteExpenseItemButton', () => {
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(DeleteExpenseItemButtonHelper.submitHandler);
const { modalElem } = await DeleteExpenseItemButtonHelper.openModal();
const { submitButtonElem } = DeleteExpenseItemFormHelper.getFormElements();
describe('should close modal after success request', () => {
let modalElem: HTMLElement;
const requestSpy = jest.fn();
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
beforeEach(async () => {
server.use(DeleteExpenseItemButtonHelper.submitHandler(requestSpy));
modalElem = (await DeleteExpenseItemButtonHelper.openModal()).modalElem;
await DeleteExpenseItemFormHelper.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(DeleteExpenseItemButtonHelper.submitHandler(requestSpy, 500));
modalElem = (await DeleteExpenseItemButtonHelper.openModal()).modalElem;
await DeleteExpenseItemFormHelper.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 DeleteExpenseItemButton: FC<DeleteExpenseItemButtonProps> = (props) => {
</Button>
<Modal onClose={onCloseModalHandler} open={isOpen}>
<DeleteExpenseItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
<DeleteExpenseItemForm 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 EditExpenseItemFormValues, type ExpenseListItem, useEditExpenseItem } from '@/entity/expenses/item';
@ -27,9 +28,30 @@ export const useEditExpenseItemButton = (args: UseAddExpenseItemButtonArgs) => {
};
const onFormSubmitHandler = async (data: EditExpenseItemFormValues) => {
// todo добавить обработку ошибок (код + тест)
await editExpenseItemMutation(data);
try {
const payment = data.paymentType === 'none'
? null
: {
id: data.paymentType === 'BANK_CARD' ? data['bank-card'] : data.cash,
type: data.paymentType,
};
await editExpenseItemMutation({
amount: data.amount,
count: data.count,
currency: 'RUB',
date: data.date.toISOString(),
description: data.description ?? null,
// @ts-ignore TODO
payment,
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 { EditExpenseItemButtonHelper, EditExpenseItemFormHelper } from '@tests/helpers';
@ -22,7 +23,7 @@ describe('TestEditExpenseItemButton', () => {
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,12 +36,12 @@ describe('TestEditExpenseItemButton', () => {
count: 1,
date: '29.08.2025',
description: 'Описание элемента',
paymentType: 'none',
title: 'Название элемента',
}, { autoSubmit: false });
});
});
test('should send correct request', async () => {
await EditExpenseItemFormHelper.submitForm();
test('should send correct request', () => {
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
amount: 12,
@ -48,15 +49,48 @@ describe('TestEditExpenseItemButton', () => {
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: null,
title: 'Название элемента',
});
});
test('should close modal after submit', async () => {
await EditExpenseItemFormHelper.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(EditExpenseItemButtonHelper.submitHandler(requestSpy, 500));
modalElem = (await EditExpenseItemButtonHelper.openModal()).modalElem;
await EditExpenseItemFormHelper.fillForm({
amount: 12,
count: 1,
date: '29.08.2025',
description: 'Описание элемента',
paymentType: 'none',
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 EditExpenseItemButton: FC<EditExpenseItemButtonProps> = (props) => {
Редактировать
</Button>
<Modal onClose={onCloseModalHandler} open={isOpen}>
<Modal onClose={onCloseModalHandler} open={isOpen || isPending}>
<EditExpenseItemForm disabled={isPending} item={item} onSubmit={onFormSubmitHandler} />
</Modal>
</>

View File

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

View File

@ -15,7 +15,7 @@ const expenseItem = {
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',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
@ -48,7 +48,7 @@ const openModal = async () => {
};
};
const submitHandler = (requestSpy: jest.Mock) => http.post<
const submitHandler = (requestSpy: jest.Mock, status = 200) => http.post<
AddExpenseItemPath,
AddExpenseItemBody,
AddExpenseItemSuccessResponse
@ -58,7 +58,7 @@ const submitHandler = (requestSpy: jest.Mock) => http.post<
const body = await request.json();
requestSpy(body);
return HttpResponse.json(expenseItem);
return HttpResponse.json(expenseItem, { status });
},
);

View File

@ -13,7 +13,7 @@ const expenseItem = {
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',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
@ -45,9 +45,13 @@ const openModal = async () => {
};
};
const submitHandler = http.delete<DeleteExpenseItemPath>(
const submitHandler = (requestSpy: jest.Mock, status = 200) => http.delete<DeleteExpenseItemPath>(
`/expenses/list/${expenseItem.expenseListId}/items/${expenseItem.id}`,
() => HttpResponse.json(expenseItem),
() => {
requestSpy(expenseItem);
return HttpResponse.json(expenseItem, { status });
},
);
export const DeleteExpenseItemButtonHelper = {

View File

@ -11,7 +11,7 @@ const expenseItem = {
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',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
@ -25,7 +25,6 @@ const renderForm = (props?: Partial<DeleteExpenseItemFormProps>) => {
render(
<DeleteExpenseItemForm
item={expenseItem}
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,7 +19,7 @@ const expenseItem = {
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',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
@ -27,6 +29,7 @@ const expenseItem = {
};
const renderButton = () => {
server.use(getBankCardList, getCashList);
render(
<EditExpenseItemButton item={expenseItem} />,
{ wrapper: createAppWrapper() },
@ -49,7 +52,7 @@ const openModal = async () => {
};
};
const submitHandler = (requestSpy: jest.Mock) => http.patch<
const submitHandler = (requestSpy: jest.Mock, status = 200) => http.patch<
EditExpenseItemPath,
EditExpenseItemBody,
EditExpenseItemSuccessResponse
@ -59,7 +62,7 @@ const submitHandler = (requestSpy: jest.Mock) => http.patch<
const body = await request.json();
requestSpy(body);
return HttpResponse.json(expenseItem);
return HttpResponse.json(expenseItem, { status });
},
);

View File

@ -1,4 +1,6 @@
import { getBankCardList, getCashList } from '@mocks/browser/handlers';
import { 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,7 +15,7 @@ const expenseItem = {
count: 12,
createdAt: '2025-08-22T07:57:40.503Z',
currency: 'RUB',
date: '2025-08-22T00:00:03.000Z',
date: '2025-08-22T00:00:00.000Z',
description: 'Mock description',
expenseListId: '10cc093c-3875-4d33-a40a-df526266e262',
id: '51a44dc0-597b-49b9-85bb-e0bcd42db3f6',
@ -23,6 +25,7 @@ const expenseItem = {
};
const renderForm = (props?: Partial<EditExpenseItemFormProps>) => {
server.use(getBankCardList, getCashList);
const onSubmit = jest.fn();
render(
@ -46,14 +49,27 @@ const getFormElements = () => {
const dateInputElem = screen.getAllByLabelText(/Дата/i)[1];
const amountInputElem = screen.getByLabelText(/Сумма/);
const countInputElem = screen.getByLabelText(/Количество/);
const submitButtonElem = screen.getByRole('button', { name: /Сохранить/i });
const submitButtonElem = screen.getByTestId(DataTestId.EDIT_EXPENSE_ITEM_SUBMIT_BUTTON);
const paymentTypeSelect = screen.getByTestId('payment-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,
countInputElem,
dateInputElem,
descriptionInputElem,
formElem,
paymentTypeSelect,
submitButtonElem,
titleInputElem,
};
@ -65,7 +81,7 @@ const submitForm = async () => {
};
const fillForm = async (
data: Omit<EditExpenseItemFormValues, 'currency'>,
data: Omit<EditExpenseItemFormValues, 'currency' | 'date'> & { date: string },
options: { autoSubmit?: boolean } = {},
) => {
const {