feat(frontend): add bank card entity
This commit is contained in:
parent
0b6f2da0d3
commit
4b2e05fa65
@ -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="/" />}>
|
||||
|
||||
12
frontend/src/entity/cards/api/addBankCard.ts
Normal file
12
frontend/src/entity/cards/api/addBankCard.ts
Normal 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;
|
||||
}
|
||||
12
frontend/src/entity/cards/api/deleteBankCard.ts
Normal file
12
frontend/src/entity/cards/api/deleteBankCard.ts
Normal 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;
|
||||
}
|
||||
13
frontend/src/entity/cards/api/editBankCard.ts
Normal file
13
frontend/src/entity/cards/api/editBankCard.ts
Normal 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;
|
||||
}
|
||||
11
frontend/src/entity/cards/api/getBankCards.ts
Normal file
11
frontend/src/entity/cards/api/getBankCards.ts
Normal 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;
|
||||
}
|
||||
17
frontend/src/entity/cards/hooks/useAddBankCard.ts
Normal file
17
frontend/src/entity/cards/hooks/useAddBankCard.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
10
frontend/src/entity/cards/hooks/useBankCards.ts
Normal file
10
frontend/src/entity/cards/hooks/useBankCards.ts
Normal 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],
|
||||
});
|
||||
17
frontend/src/entity/cards/hooks/useDeleteBankCard.ts
Normal file
17
frontend/src/entity/cards/hooks/useDeleteBankCard.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
17
frontend/src/entity/cards/hooks/useEditBankCard.ts
Normal file
17
frontend/src/entity/cards/hooks/useEditBankCard.ts
Normal 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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
17
frontend/src/entity/cards/index.ts
Normal file
17
frontend/src/entity/cards/index.ts
Normal 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';
|
||||
4
frontend/src/entity/cards/types/index.ts
Normal file
4
frontend/src/entity/cards/types/index.ts
Normal 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'];
|
||||
2
frontend/src/entity/cards/ui/AddBankCardForm/index.ts
Normal file
2
frontend/src/entity/cards/ui/AddBankCardForm/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { AddBankCardFormValues } from './ui/AddBankCardForm.ui';
|
||||
export { default as AddBankCardForm } from './ui/AddBankCardForm.ui';
|
||||
@ -0,0 +1,5 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
1
frontend/src/entity/cards/ui/DeleteBankCardForm/index.ts
Normal file
1
frontend/src/entity/cards/ui/DeleteBankCardForm/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as DeleteBankCardForm } from './ui/DeleteBankCardForm.ui';
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
2
frontend/src/entity/cards/ui/EditBankCardForm/index.ts
Normal file
2
frontend/src/entity/cards/ui/EditBankCardForm/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export type { EditBankCardFormValues } from './ui/EditBankCardForm.ui';
|
||||
export { default as EditBankCardForm } from './ui/EditBankCardForm.ui';
|
||||
@ -0,0 +1,5 @@
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -92,6 +92,7 @@ const AddExpenseItemForm: FC<AddExpenseItemFormProps> = (props) => {
|
||||
disabled={disabled}
|
||||
label="Сумма"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
|
||||
@ -95,6 +95,7 @@ const EditExpenseItemForm: FC<EditExpenseItemFormProps> = (props) => {
|
||||
disabled={disabled}
|
||||
label="Сумма"
|
||||
name="amount"
|
||||
step="0.01"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
@ -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],
|
||||
});
|
||||
|
||||
@ -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],
|
||||
});
|
||||
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export { default as AddBankCardButton } from './ui/AddBankCardButton.ui';
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export { default as DeleteBankCardButton } from './ui/DeleteBankCardButton.ui';
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
@ -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,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1 @@
|
||||
export { default as EditBankCardButton } from './ui/EditBankCardButton.ui';
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
1
frontend/src/pages/BankCard/index.ts
Normal file
1
frontend/src/pages/BankCard/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as BankCardsPage } from './ui/BankCardsPage.ui';
|
||||
40
frontend/src/pages/BankCard/ui/BankCardsPage.ui.tsx
Normal file
40
frontend/src/pages/BankCard/ui/BankCardsPage.ui.tsx
Normal 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;
|
||||
7
frontend/src/shared/api/queryKeys.ts
Normal file
7
frontend/src/shared/api/queryKeys.ts
Normal file
@ -0,0 +1,7 @@
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention
|
||||
export enum QueryKeys {
|
||||
BankCards = 'BankCards',
|
||||
BankCard = 'BankCard',
|
||||
ExpensesList = 'ExpensesList',
|
||||
ExpenseList = 'ExpenseList',
|
||||
}
|
||||
@ -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;
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@ export enum PAGES {
|
||||
Register = '/register',
|
||||
Expenses = '/expenses',
|
||||
Expense = '/expenses/:id',
|
||||
BankCards = '/bank-cards',
|
||||
}
|
||||
|
||||
export type ExpensePageParams = {
|
||||
|
||||
8
frontend/src/shared/utils/formatters/currency.ts
Normal file
8
frontend/src/shared/utils/formatters/currency.ts
Normal 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);
|
||||
}
|
||||
@ -1,3 +1,5 @@
|
||||
export const wait = async (time: number) => new Promise((res) => {
|
||||
setTimeout(res, time);
|
||||
});
|
||||
|
||||
export { formatRub } from './formatters/currency';
|
||||
|
||||
@ -18,6 +18,12 @@ const SideBar = () => (
|
||||
Расходы
|
||||
</NavLink>
|
||||
</li>
|
||||
|
||||
<li>
|
||||
<NavLink to={PAGES.BankCards}>
|
||||
Карты
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
|
||||
1
frontend/src/widgets/bank-cards/BankCardsTable/index.ts
Normal file
1
frontend/src/widgets/bank-cards/BankCardsTable/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default as BankCardsTable } from './ui/BankCardsTable.ui';
|
||||
@ -0,0 +1,3 @@
|
||||
.table, th, td {
|
||||
border: 1px solid;
|
||||
}
|
||||
@ -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;
|
||||
Loading…
x
Reference in New Issue
Block a user