feat(frontend): add bank card entity

This commit is contained in:
Sergey Krylov 2025-09-11 05:38:08 +03:00
parent 0b6f2da0d3
commit 4b2e05fa65
55 changed files with 1534 additions and 14 deletions

View File

@ -7,6 +7,7 @@ import {
} from 'react-router';
import { LoginPage, RegisterPage } from '@/pages/Authorization';
import { BankCardsPage } from '@/pages/BankCard';
import { ExpensePage } from '@/pages/Expense';
import { ExpensesPage } from '@/pages/Expenses';
import { HomePage } from '@/pages/Home';
@ -27,6 +28,8 @@ const App = () => (
<Route element={<ExpensesPage />} path={PAGES.Expenses} />
<Route element={<ExpensePage />} path={PAGES.Expense} />
<Route element={<BankCardsPage />} path={PAGES.BankCards} />
</Route>
<Route element={<AuthRoute needAuth={false} to="/" />}>

View File

@ -0,0 +1,12 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type AddBankCardBody = paths['/bank-card']['post']['requestBody']['content']['application/json'];
export type AddBankCardResponse = paths['/bank-card']['post']['responses']['200']['content']['application/json'];
export async function addBankCard(data: AddBankCardBody) {
const { data: result } = await api.post<AddBankCardResponse>('/bank-card', data);
return result;
}

View File

@ -0,0 +1,12 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type DeleteBankCardPath = paths['/bank-card/{id}']['delete']['parameters']['path'];
export type DeleteBankCardResponse = paths['/bank-card/{id}']['delete']['responses']['200']['content']['application/json'];
export async function deleteBankCard(path: DeleteBankCardPath) {
const { data: result } = await api.delete<DeleteBankCardResponse>(`/bank-card/${path.id}`);
return result;
}

View File

@ -0,0 +1,13 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
export type EditBankCardPath = paths['/bank-card/{id}']['patch']['parameters']['path'];
export type EditBankCardBody = paths['/bank-card/{id}']['patch']['requestBody']['content']['application/json'];
export type EditBankCardResponse = paths['/bank-card/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editBankCard({ path, data }: { path: EditBankCardPath; data: EditBankCardBody }) {
const { data: result } = await api.patch<EditBankCardResponse>(`/bank-card/${path.id}`, data);
return result;
}

View File

@ -0,0 +1,11 @@
import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type BankCardsResponse = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
export async function getBankCards() {
const { data: result } = await api.get<BankCardsResponse>('/bank-card');
return result;
}

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { addBankCard } from '../api/addBankCard';
export const useAddBankCard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: addBankCard,
mutationKey: [QueryKeys.BankCards],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] });
},
});
};

View File

@ -0,0 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { getBankCards } from '../api/getBankCards';
export const useBankCards = () => useQuery({
queryFn: async () => getBankCards(),
queryKey: [QueryKeys.BankCards],
});

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { deleteBankCard } from '../api/deleteBankCard';
export const useDeleteBankCard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: deleteBankCard,
mutationKey: [QueryKeys.BankCards],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] });
},
});
};

View File

@ -0,0 +1,17 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { editBankCard } from '../api/editBankCard';
export const useEditBankCard = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: editBankCard,
mutationKey: [QueryKeys.BankCards],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.BankCards] });
},
});
};

View File

@ -0,0 +1,17 @@
export { useBankCards } from './hooks/useBankCards';
export { useAddBankCard } from './hooks/useAddBankCard';
export { useDeleteBankCard } from './hooks/useDeleteBankCard';
export { useEditBankCard } from './hooks/useEditBankCard';
export { AddBankCardForm } from './ui/AddBankCardForm';
export { DeleteBankCardForm } from './ui/DeleteBankCardForm';
export { EditBankCardForm } from './ui/EditBankCardForm';
export type { AddBankCardBody, AddBankCardResponse } from './api/addBankCard';
export type { DeleteBankCardPath, DeleteBankCardResponse } from './api/deleteBankCard';
export type { EditBankCardBody, EditBankCardPath, EditBankCardResponse } from './api/editBankCard';
export type { AddBankCardFormValues } from './ui/AddBankCardForm';
export type { EditBankCardFormValues } from './ui/EditBankCardForm';
export type { BankCards, BankCard } from './types';

View File

@ -0,0 +1,4 @@
import type { paths } from '@/shared/api/schema';
export type BankCards = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
export type BankCard = paths['/bank-card/{id}']['get']['responses']['200']['content']['application/json'];

View File

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

View File

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

View File

@ -0,0 +1,64 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import AddBankCardForm from './AddBankCardForm.ui';
import type { AddBankCardFormProps } from './AddBankCardForm.ui';
const renderForm = (props?: Partial<AddBankCardFormProps>) => {
const onSubmit = jest.fn();
render(<AddBankCardForm data-testid="add-balance-card-form" onSubmit={onSubmit} {...props} />);
const nameInputElem = screen.getByLabelText('Название');
const balanceInputElem = screen.getByLabelText('Баланс');
const submitButtonElem = screen.getByText('Добавить');
return {
nameInputElem,
balanceInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test AddBankCardForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('add-balance-card-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => {
expect(el).toBeEnabled();
}],
['disabled', { disabled: true }, (el: HTMLElement) => {
expect(el).toBeDisabled();
}],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
nameInputElem,
balanceInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.type(nameInputElem, 'Тестовая карта');
await userEvent.type(balanceInputElem, '500');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
name: 'Тестовая карта',
balance: 500,
});
});
});

View File

@ -0,0 +1,79 @@
import { Input } from '@/shared/ui/Input';
import classes from './AddBankCardForm.module.css';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
export type AddBankCardFormValues = {
name: string;
balance: number;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type AddBankCardFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddBankCardFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const AddBankCardForm: FC<AddBankCardFormProps> = (props) => {
const {
ref,
disabled,
onSubmit,
...formProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get('name');
const balance = formData.get('balance');
if (typeof name === 'string' && typeof balance === 'string') {
onSubmit?.({
name,
balance: Number(balance),
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...formProps}
>
<h2>Добавить карту</h2>
<Input
required
disabled={disabled}
label="Название"
name="name"
/>
<Input
defaultValue={0}
disabled={disabled}
label="Баланс"
name="balance"
step="0.01"
type="number"
/>
<button disabled={disabled} type="submit">Добавить</button>
</form>
);
};
export default AddBankCardForm;

View File

@ -0,0 +1 @@
export { default as DeleteBankCardForm } from './ui/DeleteBankCardForm.ui';

View File

@ -0,0 +1,55 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DeleteBankCardForm from './DeleteBankCardForm.ui';
import type { DeleteBankCardFormProps } from './DeleteBankCardForm.ui';
const deletingBankCard: DeleteBankCardFormProps['bankCard'] = {
id: 'af6da4e1-f24f-4b43-8556-fa885626897d',
name: 'Тестовая карта',
balance: 1000,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial<DeleteBankCardFormProps>) => {
const onSubmit = jest.fn();
render(
<DeleteBankCardForm
bankCard={deletingBankCard}
data-testid="delete-bank-card-form"
onSubmit={onSubmit}
{...props}
/>,
);
const submitButtonElem = screen.getByText('Удалить');
return {
submitButtonElem,
onSubmit,
};
};
describe('Test DeleteExpensesListForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('delete-bank-card-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should call onSubmit with values', async () => {
const {
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(deletingBankCard);
});
});

View File

@ -0,0 +1,53 @@
import type { BankCard } from '../../../types';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type DeleteBankCardFormProps = BaseFormProps & {
bankCard: BankCard;
disabled?: boolean;
onSubmit?: (bankCard: BankCard) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const DeleteBankCardForm: FC<DeleteBankCardFormProps> = (props) => {
const {
bankCard,
disabled,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(bankCard);
};
return (
<form ref={ref} onSubmit={onFormSubmitHandler} {...restProps}>
<h2>Удалить список расходов</h2>
<p>
Вы действительно хотите удалить банковскую карту
{' '}
<b>
{`"${bankCard.name}"`}
</b>
{' '}
?
</p>
<button disabled={disabled} type="submit">Удалить</button>
</form>
);
};
export default DeleteBankCardForm;

View File

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

View File

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

View File

@ -0,0 +1,91 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import EditBankCardForm from './EditBankCardForm.ui';
import type { EditBankCardFormProps } from './EditBankCardForm.ui';
const editingBankCard = {
id: 'af6da4e1-f24f-4b43-8556-fa885626897d',
name: 'Тестовая карта',
balance: 1000,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderForm = (props?: Partial<EditBankCardFormProps>) => {
const onSubmit = jest.fn();
render(
<EditBankCardForm
bankCard={editingBankCard}
data-testid="edit-bank-card-form"
onSubmit={onSubmit}
{...props}
/>,
);
const titleInputElem = screen.getByLabelText('Название');
const balanceInputElem = screen.getByLabelText('Баланс');
const submitButtonElem = screen.getByText('Сохранить');
return {
titleInputElem,
balanceInputElem,
submitButtonElem,
onSubmit,
};
};
describe('Test EditBankCardForm', () => {
test('should render form', () => {
renderForm();
const form = screen.getByTestId('edit-bank-card-form');
expect(form).toBeInTheDocument();
expect(form).toBeVisible();
});
test('should inputs have default value', () => {
const {
titleInputElem,
balanceInputElem,
} = renderForm();
expect(titleInputElem).toHaveValue('Тестовая карта');
expect(balanceInputElem).toHaveValue(1000);
});
test.each([
['enabled', {}, (el: HTMLElement) => {
expect(el).toBeEnabled();
}],
['disabled', { disabled: true }, (el: HTMLElement) => {
expect(el).toBeDisabled();
}],
])('should render fields %s', (_state, options, assertion) => {
const { onSubmit, ...formElems } = renderForm(options);
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const {
titleInputElem,
balanceInputElem,
submitButtonElem,
onSubmit,
} = renderForm();
await userEvent.clear(titleInputElem);
await userEvent.type(titleInputElem, 'Новое название карты');
await userEvent.clear(balanceInputElem);
await userEvent.type(balanceInputElem, '5000');
await userEvent.click(submitButtonElem);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
name: 'Новое название карты',
balance: 5000,
});
});
});

View File

@ -0,0 +1,83 @@
import { Input } from '@/shared/ui/Input';
import classes from './EditBankCardForm.module.css';
import type { BankCard } from '@/entity/cards';
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
export type EditBankCardFormValues = {
name: string;
balance: number;
};
export type EditBankCardFormProps = BaseFormProps & {
bankCard: BankCard;
disabled?: boolean;
onSubmit?: (data: EditBankCardFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
const EditBankCardForm: FC<EditBankCardFormProps> = (props) => {
const {
bankCard,
disabled,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
const formData = new FormData(event.currentTarget);
const name = formData.get('name');
const balance = formData.get('balance');
if (typeof name === 'string' && typeof balance === 'string') {
onSubmit?.({
name,
balance: Number(balance),
});
}
};
return (
<form
ref={ref}
className={classes.form}
onSubmit={onFormSubmitHandler}
{...restProps}
>
<h2>Добавить карту</h2>
<Input
required
defaultValue={bankCard.name}
disabled={disabled}
label="Название"
name="name"
/>
<Input
defaultValue={bankCard.balance}
disabled={disabled}
label="Баланс"
name="balance"
step="0.01"
type="number"
/>
<button disabled={disabled} type="submit">Сохранить</button>
</form>
);
};
export default EditBankCardForm;

View File

@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { addExpenseItem, type AddExpenseItemBody, type AddExpenseItemPath } from '../api/addExpenseItem';
export const useAddExpenseItem = (path: AddExpenseItemPath) => {
@ -7,9 +9,9 @@ export const useAddExpenseItem = (path: AddExpenseItemPath) => {
return useMutation({
mutationFn: async (data: AddExpenseItemBody) => addExpenseItem(path, data),
mutationKey: ['expenseList', path.id],
mutationKey: [QueryKeys.ExpenseList, path.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expenseList', path.id] });
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.id] });
},
});
};

View File

@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { deleteExpenseItem } from '../api/deleteExpenseItem';
import type { DeleteExpenseItemPath } from '../api/deleteExpenseItem';
@ -9,9 +11,9 @@ export const useDeleteExpenseItem = (path: DeleteExpenseItemPath) => {
return useMutation({
mutationFn: async () => deleteExpenseItem(path),
mutationKey: ['expenseList', path.listId],
mutationKey: [QueryKeys.ExpenseList, path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expenseList', path.listId] });
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] });
},
});
};

View File

@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { editExpenseItem, type EditExpenseItemBody, type EditExpenseItemPath } from '../api/editExpenseItem';
export const useEditExpenseItem = (path: EditExpenseItemPath) => {
@ -7,9 +9,9 @@ export const useEditExpenseItem = (path: EditExpenseItemPath) => {
return useMutation({
mutationFn: async (data: EditExpenseItemBody) => editExpenseItem(path, data),
mutationKey: ['expenseList', path.listId],
mutationKey: [QueryKeys.ExpenseList, path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expenseList', path.listId] });
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] });
},
});
};

View File

@ -92,6 +92,7 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
disabled={disabled}
label="Сумма"
name="amount"
step="0.01"
type="number"
/>

View File

@ -95,6 +95,7 @@ const EditExpenseItemForm: FC<EditExpenseItemFormProps> = (props) => {
disabled={disabled}
label="Сумма"
name="amount"
step="0.01"
type="number"
/>

View File

@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { addExpensesList } from '../api/addExpensesList';
export const useAddExpensesList = () => {
@ -7,9 +9,9 @@ export const useAddExpensesList = () => {
return useMutation({
mutationFn: addExpensesList,
mutationKey: ['expensesList'],
mutationKey: [QueryKeys.ExpenseList],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expensesList'] });
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpensesList] });
},
});
};

View File

@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { deleteExpensesList } from '../api/deleteExpensesList';
export const useDeleteExpensesList = () => {
@ -7,9 +9,9 @@ export const useDeleteExpensesList = () => {
return useMutation({
mutationFn: deleteExpensesList,
mutationKey: ['expensesList'],
mutationKey: [QueryKeys.ExpenseList],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expensesList'] });
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpensesList] });
},
});
};

View File

@ -1,5 +1,7 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { editExpensesList } from '../api/editExpensesList';
export const useEditExpensesList = () => {
@ -7,9 +9,9 @@ export const useEditExpensesList = () => {
return useMutation({
mutationFn: editExpensesList,
mutationKey: ['expensesList'],
mutationKey: [QueryKeys.ExpenseList],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['expensesList'] });
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpensesList] });
},
});
};

View File

@ -1,10 +1,12 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { getExpenseList } from '../api/getExpenseList';
import type { ExpenseList } from '../types';
export const useExpenseList = (id: ExpenseList['id']) => useQuery({
queryFn: async () => getExpenseList({ id }),
queryKey: ['expenseList', id],
queryKey: [QueryKeys.ExpenseList, id],
});

View File

@ -1,8 +1,10 @@
import { useQuery } from '@tanstack/react-query';
import { QueryKeys } from '@/shared/api/queryKeys';
import { getExpenseLists } from '../api/getExpenseLists';
export const useExpensesList = () => useQuery({
queryFn: getExpenseLists,
queryKey: ['expensesList'],
queryKey: [QueryKeys.ExpensesList],
});

View File

@ -0,0 +1,38 @@
import { type MouseEventHandler, useState } from 'react';
import { useAddBankCard } from '@/entity/cards';
import type { AddBankCardFormValues } from '@/entity/cards';
type UseAddBankCardButtonArgs = {
onClick?: MouseEventHandler<HTMLButtonElement>;
};
export const useAddBankCardButton = (args: UseAddBankCardButtonArgs) => {
const { onClick } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: addBankCardMutation, isPending } = useAddBankCard();
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onClickHandler: MouseEventHandler<HTMLButtonElement> = (event) => {
onClick?.(event);
setIsOpen(true);
};
const onFormSubmitHandler = async (data: AddBankCardFormValues) => {
await addBankCardMutation(data);
setIsOpen(false);
};
return {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as AddBankCardButton } from './ui/AddBankCardButton.ui';

View File

@ -0,0 +1,107 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import AddBankCardButton from './AddBankCardButton.ui';
import type { AddBankCardBody, AddBankCardResponse } from '@/entity/cards';
const renderButton = () => {
render(
<AddBankCardButton data-testid="test-button" />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId('test-button');
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const fillForm = async (data: AddBankCardBody) => {
await userEvent.type(screen.getByLabelText('Название'), data.name);
await userEvent.type(screen.getByLabelText('Баланс'), String(data.balance));
};
const submitHandler = (requestSpy: jest.Mock) => http.post<never, AddBankCardBody, AddBankCardResponse>(
'/bank-card',
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json({
id: '10cc093c-3875-4d33-a40a-df526266e262',
name: 'Название карты',
balance: 999,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
});
},
);
describe('Test AddBankCardButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Добавить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = within(modalElem).getByText('Добавить');
await fillForm({
balance: 999,
name: 'Тестовая карта',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
balance: 999,
name: 'Тестовая карта',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,41 @@
import { AddBankCardForm } from '@/entity/cards';
import { Modal } from '@/shared/ui/Modal';
import { useAddBankCardButton } from '../hooks/useAddBankCardButton';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
type AddBankCardButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
ref?: Ref<HTMLButtonElement>;
};
const AddBankCardButton: FC<AddBankCardButtonProps> = (props) => {
const { ref, onClick, ...restProps } = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useAddBankCardButton({ onClick });
return (
<>
<button
ref={ref}
type="button"
onClick={onClickHandler}
{...restProps}
>
Добавить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<AddBankCardForm disabled={isPending} onSubmit={onFormSubmitHandler} />
</Modal>
</>
);
};
export default AddBankCardButton;

View File

@ -0,0 +1,37 @@
import { useState } from 'react';
import { useDeleteBankCard } from '@/entity/cards';
import type { BankCard } from '@/entity/cards';
type UseDeleteBankCardButtonArgs = {
id: BankCard['id'];
};
export const useDeleteBankCardButton = (args: UseDeleteBankCardButtonArgs) => {
const { id } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: deleteBankCardAsync, isPending } = useDeleteBankCard();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler = async () => {
await deleteBankCardAsync({ id });
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as DeleteBankCardButton } from './ui/DeleteBankCardButton.ui';

View File

@ -0,0 +1,76 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import DeleteBankCardButton from './DeleteBankCardButton.ui';
import type { BankCard } from '@/entity/cards';
import type { DeleteExpenseListPath } from '@/entity/expenses/list';
const deletingBankCard: BankCard = {
id: 'af6da4e1-f24f-4b43-8556-fa885626897d',
name: 'Тестовая карта',
balance: 1000,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const renderButton = () => {
const testId = 'delete-bank-card-button';
render(
<DeleteBankCardButton bankCard={deletingBankCard} data-testid={testId} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId(testId);
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = http.delete<DeleteExpenseListPath>(`/bank-card/${deletingBankCard.id}`, () => HttpResponse.json(deletingBankCard));
describe('Test DeleteBankCardButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Удалить');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
test('should close modal after submit', async () => {
server.use(submitHandler);
const { modalElem } = await openModal();
const submitButtonElem = within(modalElem).getByText('Удалить');
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,47 @@
import { DeleteBankCardForm } from '@/entity/cards';
import { Modal } from '@/shared/ui/Modal';
import { useDeleteBankCardButton } from '../hooks/useDeleteBankCardButton';
import type { BankCard } from '@/entity/cards';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
export type DeleteBankCardButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
bankCard: BankCard;
ref?: Ref<HTMLButtonElement>;
};
const DeleteBankCardButton: FC<DeleteBankCardButtonProps> = (props) => {
const {
bankCard,
ref,
onClick,
...restProps
} = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useDeleteBankCardButton({ id: bankCard.id });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Удалить
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<DeleteBankCardForm
bankCard={bankCard}
disabled={isPending}
onSubmit={onFormSubmitHandler}
/>
</Modal>
</>
);
};
export default DeleteBankCardButton;

View File

@ -0,0 +1,40 @@
import { useState } from 'react';
import { useEditBankCard } from '@/entity/cards';
import type { BankCard, EditBankCardFormValues } from '@/entity/cards';
type UseDeleteBankCardButtonArgs = {
bankCard: BankCard;
};
export const useEditBankCardButton = (args: UseDeleteBankCardButtonArgs) => {
const { bankCard } = args;
const [isOpen, setIsOpen] = useState(false);
const { mutateAsync: editBankCardAsync, isPending } = useEditBankCard();
const onClickHandler = () => {
setIsOpen(true);
};
const onCloseModalHandler = () => {
setIsOpen(false);
};
const onFormSubmitHandler = async (data: EditBankCardFormValues) => {
await editBankCardAsync({
data,
path: { id: bankCard.id },
});
setIsOpen(false);
};
return {
isOpen,
isPending,
onClickHandler,
onCloseModalHandler,
onFormSubmitHandler,
};
};

View File

@ -0,0 +1 @@
export { default as EditBankCardButton } from './ui/EditBankCardButton.ui';

View File

@ -0,0 +1,127 @@
import { render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '@mocks/jest/server';
import { createReactQueryWrapper } from '@mocks/jest/wrappers';
import EditBankCardButton from './EditBankCardButton.ui';
import type {
BankCard,
EditBankCardBody,
EditBankCardPath,
EditBankCardResponse,
} from '@/entity/cards';
const editingBankCard: BankCard = {
id: 'af6da4e1-f24f-4b43-8556-fa885626897d',
name: 'Тестовая карта',
balance: 1000,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
};
const fillForm = async (data: EditBankCardBody) => {
const nameInputElem = screen.getByLabelText('Название');
const balanceInputElem = screen.getByLabelText('Баланс');
await userEvent.clear(nameInputElem);
await userEvent.type(nameInputElem, data.name);
await userEvent.clear(balanceInputElem);
await userEvent.type(balanceInputElem, String(data.balance));
};
const renderButton = () => {
const testId = 'edit-bank-card-button';
render(
<EditBankCardButton bankCard={editingBankCard} data-testid={testId} />,
{ wrapper: createReactQueryWrapper() },
);
const buttonElem = screen.getByTestId(testId);
return {
buttonElem,
};
};
const openModal = async () => {
const { buttonElem } = renderButton();
await userEvent.click(buttonElem);
const modalElem = screen.getByRole('dialog');
return {
modalElem,
};
};
const submitHandler = (requestSpy: jest.Mock) => http.patch<EditBankCardPath, EditBankCardBody, EditBankCardResponse>(
`/bank-card/${editingBankCard.id}`,
async ({ request }) => {
const body = await request.json();
requestSpy(body);
return HttpResponse.json({
id: 'af6da4e1-f24f-4b43-8556-fa885626897d',
name: 'Тестовая карта',
balance: 1000,
updatedAt: '2025-08-29T07:57:40.503Z',
createdAt: '2025-08-29T07:57:40.503Z',
});
},
);
describe('Test EditBankCardButton', () => {
test('should correct render button', () => {
const { buttonElem } = renderButton();
expect(buttonElem).toBeInTheDocument();
expect(buttonElem).toBeVisible();
expect(buttonElem).toBeEnabled();
expect(buttonElem).toHaveTextContent('Редактировать');
const modal = screen.queryByRole('dialog');
expect(modal).not.toBeInTheDocument();
});
test('should open modal after click', async () => {
const { modalElem } = await openModal();
expect(modalElem).toBeInTheDocument();
expect(modalElem).toBeVisible();
});
describe('should close modal after submit and send request', () => {
let modalElem: HTMLElement;
let submitButtonElem: HTMLElement;
const requestSpy = jest.fn();
beforeEach(async () => {
server.use(submitHandler(requestSpy));
modalElem = (await openModal()).modalElem;
submitButtonElem = within(modalElem).getByText('Сохранить');
await fillForm({
balance: 999,
name: 'Тестовая карта',
});
});
test('should send correct request', async () => {
await userEvent.click(submitButtonElem);
expect(requestSpy).toHaveBeenCalledTimes(1);
expect(requestSpy).toHaveBeenCalledWith({
balance: 999,
name: 'Тестовая карта',
});
});
test('should close modal after submit', async () => {
expect(submitButtonElem).toBeEnabled();
await userEvent.click(submitButtonElem);
expect(modalElem).not.toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,46 @@
import { EditBankCardForm } from '@/entity/cards';
import { Modal } from '@/shared/ui/Modal';
import { useEditBankCardButton } from '../hooks/useEditBankCardButton';
import type { BankCard } from '@/entity/cards';
import type { ButtonHTMLAttributes, FC, Ref } from 'react';
export type EditBankCardButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
bankCard: BankCard;
ref?: Ref<HTMLButtonElement>;
};
const EditBankCardButton: FC<EditBankCardButtonProps> = (props) => {
const {
bankCard,
ref,
onClick,
...restProps
} = props;
const {
isOpen,
isPending,
onCloseModalHandler,
onClickHandler,
onFormSubmitHandler,
} = useEditBankCardButton({ bankCard });
return (
<>
<button type="button" onClick={onClickHandler} {...restProps}>
Редактировать
</button>
<Modal open={isOpen} onClose={onCloseModalHandler}>
<EditBankCardForm
bankCard={bankCard}
disabled={isPending}
onSubmit={onFormSubmitHandler}
/>
</Modal>
</>
);
};
export default EditBankCardButton;

View File

@ -0,0 +1 @@
export { default as BankCardsPage } from './ui/BankCardsPage.ui';

View File

@ -0,0 +1,40 @@
import { useBankCards } from '@/entity/cards';
import { AddBankCardButton } from '@/features/bank-cards/AddBankCardButton';
import { Spinner } from '@/shared/ui/Spinner';
import { AppLayout } from '@/widgets/AppLayout';
import { BankCardsTable } from '@/widgets/bank-cards/BankCardsTable';
import type { FC } from 'react';
const BankCardsPage: FC = () => {
const { isPending, data: bankCards } = useBankCards();
const renderContent = () => {
if (isPending) {
return <Spinner />;
}
if (bankCards && bankCards.length > 0) {
return <BankCardsTable items={bankCards} />;
}
return <p>Нет добавленных карт</p>;
};
return (
<AppLayout>
<div>
<h1>Мои карты</h1>
<div>
<AddBankCardButton />
</div>
{renderContent()}
</div>
</AppLayout>
);
};
export default BankCardsPage;

View File

@ -0,0 +1,7 @@
// eslint-disable-next-line @typescript-eslint/naming-convention
export enum QueryKeys {
BankCards = 'BankCards',
BankCard = 'BankCard',
ExpensesList = 'ExpensesList',
ExpenseList = 'ExpenseList',
}

View File

@ -201,6 +201,58 @@ export interface paths {
patch: operations["ExpensesController_editListItem"];
trace?: never;
};
"/bank-card": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти все карты
* @description Найти все банковсие карты
*/
get: operations["BankCardController_getBankCards"];
put?: never;
/**
* Создать карту
* @description Создать банковскую карту
*/
post: operations["BankCardController_createBankCard"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/bank-card/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Найти карту
* @description Найти банковскую карту
*/
get: operations["BankCardController_getBankCardById"];
put?: never;
post?: never;
/**
* Удалить карту
* @description Удалить банковскую карту
*/
delete: operations["BankCardController_deleteBankCardById"];
options?: never;
head?: never;
/**
* Редактировать карту
* @description Редактировать банковскую карту
*/
patch: operations["BankCardController_editBankCard"];
trace?: never;
};
}
export type webhooks = Record<string, never>;
export interface components {
@ -430,6 +482,54 @@ export interface components {
*/
count: number | null;
};
CreateBankCardDto: {
/**
* @description Название карты
* @example Карта Сбербанка
*/
name: string;
/**
* @description Баланс карты
* @example 100.95
*/
balance: number;
};
BankCardDto: {
/** @description ID */
id: string;
/**
* @description Название карты
* @example Карта Сбербанка
*/
name: string;
/**
* @description Баланс карты
* @example 100.95
*/
balance: number;
/**
* Format: date-time
* @description Дата создания
*/
createdAt: string;
/**
* Format: date-time
* @description Дата обновления
*/
updatedAt: string;
};
EditBankCardDto: {
/**
* @description Название карта
* @example Карта Сбербанка
*/
name: string;
/**
* @description Баланс карты
* @example 100.95
*/
balance: number;
};
};
responses: never;
parameters: never;
@ -859,4 +959,142 @@ export interface operations {
};
};
};
BankCardController_getBankCards: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно найдены банковские карты */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BankCardDto"][];
};
};
};
};
BankCardController_createBankCard: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["CreateBankCardDto"];
};
};
responses: {
/** @description Успешно создана банковская карта */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BankCardDto"];
};
};
};
};
BankCardController_getBankCardById: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID карты */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно найдена банковская карта */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BankCardDto"];
};
};
/** @description Карта не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
BankCardController_deleteBankCardById: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID карты */
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Успешно удалена банковская карта */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BankCardDto"];
};
};
/** @description Карта не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
BankCardController_editBankCard: {
parameters: {
query?: never;
header?: never;
path: {
/** @description ID карты */
id: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["EditBankCardDto"];
};
};
responses: {
/** @description Успешно отредактирована банковская карта */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["BankCardDto"];
};
};
/** @description Карта не найдена */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
}

View File

@ -4,6 +4,7 @@ export enum PAGES {
Register = '/register',
Expenses = '/expenses',
Expense = '/expenses/:id',
BankCards = '/bank-cards',
}
export type ExpensePageParams = {

View File

@ -0,0 +1,8 @@
const rubFormatter = new Intl.NumberFormat('ru-RU', {
style: 'currency',
currency: 'RUB',
});
export function formatRub(value: number): string {
return rubFormatter.format(value);
}

View File

@ -1,3 +1,5 @@
export const wait = async (time: number) => new Promise((res) => {
setTimeout(res, time);
});
export { formatRub } from './formatters/currency';

View File

@ -18,6 +18,12 @@ const SideBar = () => (
Расходы
</NavLink>
</li>
<li>
<NavLink to={PAGES.BankCards}>
Карты
</NavLink>
</li>
</ul>
</div>
);

View File

@ -0,0 +1 @@
export { default as BankCardsTable } from './ui/BankCardsTable.ui';

View File

@ -0,0 +1,3 @@
.table, th, td {
border: 1px solid;
}

View File

@ -0,0 +1,60 @@
import { DeleteBankCardButton } from '@/features/bank-cards/DeleteBankCardButton';
import { EditBankCardButton } from '@/features/bank-cards/EditBankCardButton';
import { formatRub } from '@/shared/utils';
import classes from './BankCardsTable.module.css';
import type { BankCards } from '@/entity/cards';
import type { FC } from 'react';
type BankCardsTableProps = {
items: BankCards;
};
const BankCardsTable: FC<BankCardsTableProps> = (props) => {
const {
items,
} = props;
return (
<table className={classes.table}>
<thead>
<tr>
<th>Идентификатор</th>
<th>Название</th>
<th>Баланс карты</th>
<th>Действия</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id}>
<td>
{item.id}
</td>
<td>
{item.name}
</td>
<td>
{formatRub(item.balance)}
</td>
<td>
<EditBankCardButton bankCard={item} />
<DeleteBankCardButton bankCard={item} />
</td>
</tr>
))}
</tbody>
</table>
);
};
export default BankCardsTable;