Compare commits

..

23 Commits

Author SHA1 Message Date
5a5c308e45 fix(server): fix token lifetime 2025-10-09 05:47:29 +03:00
e52dd11a16 refactor(frontend): refactor unit tests 2025-10-09 05:46:35 +03:00
021038f4ad feat(frontend): add mui 2025-10-09 05:44:51 +03:00
35291a103a feat(frontend): add setup tests 2025-10-09 05:44:51 +03:00
6ed6cf8782 feat(frontend): add payment type 2025-09-25 04:59:12 +03:00
3fba931281 feat(server): add payment type 2025-09-25 04:58:58 +03:00
457f4bbed2 feat(frontend): update eslint rules 2025-09-17 06:14:33 +03:00
dd9ae0b7c0 feat(frontend): update dependencies 2025-09-17 06:14:11 +03:00
9a7d444d9d feat(frontend): add income 2025-09-16 05:59:39 +03:00
ee6276e8ab feat(server): add income 2025-09-16 05:57:06 +03:00
59ff36cb18 feat(frontend): add cash 2025-09-12 07:28:01 +03:00
e7c0a48ed1 feat(server): add cash 2025-09-12 07:27:00 +03:00
4b2e05fa65 feat(frontend): add bank card entity 2025-09-11 05:38:08 +03:00
0b6f2da0d3 feat(server): add bank card entity 2025-09-11 05:33:28 +03:00
920daee26e feat: add git hook checking 2025-09-05 05:52:57 +03:00
07367c4897 refactor(frontend): fix eslint config 2025-09-05 05:19:56 +03:00
af0436c655 refactor(frontend): update deps 2025-09-05 05:19:40 +03:00
f19b7e231f refactor(frontend): fix eslint 2025-09-04 05:54:57 +03:00
5b91157e6c feat(frontend): add mock dev mode 2025-09-04 05:54:06 +03:00
06897a86db feat(frontend): add tests form expense list and items 2025-09-03 06:22:06 +03:00
9cb8c376dd feat(frontend): add jest 2025-09-03 06:20:42 +03:00
10c0e5cf7c feat(frontend): add expenses 2025-08-28 05:09:02 +03:00
d60aae26e6 feat(backend): add expenses 2025-08-28 05:09:02 +03:00
443 changed files with 25218 additions and 1230 deletions

6
.env Normal file
View File

@ -0,0 +1,6 @@
POSTGRES_USER='root'
POSTGRES_PASSWORD='123456'
POSTGRES_HOST='localhost'
POSTGRES_PORT='5433'
POSTGRES_DATABASE='investments'
POSTGRES_URI="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DATABASE}"

1
commitlint.config.mjs Normal file
View File

@ -0,0 +1 @@
export default { extends: ['@commitlint/config-conventional'] };

View File

@ -0,0 +1,3 @@
BACKEND_BASE_URL="http://localhost:3000"
PUBLIC_API_BASE_URL="/api"
PUBLIC_ENABLE_MOCKING=false

2
frontend/.env.mock Normal file
View File

@ -0,0 +1,2 @@
PUBLIC_API_BASE_URL="/"
PUBLIC_ENABLE_MOCKING=true

2
frontend/.gitignore vendored
View File

@ -14,3 +14,5 @@ dist/
.vscode/* .vscode/*
!.vscode/extensions.json !.vscode/extensions.json
.idea .idea
coverage

View File

@ -0,0 +1,23 @@
export const bankCardList = [
{
balance: 0,
createdAt: '2025-09-09T01:58:06.993Z',
id: 'e2a7beee-2a1f-4881-97c5-0a7793c78adf',
name: 'Сбербанк',
updatedAt: '2025-09-09T01:58:06.993Z',
},
{
balance: 100,
createdAt: '2025-09-09T01:59:47.698Z',
id: '4360f93c-0e8e-4285-9b0d-ba5701f43013',
name: 'Тинькофф !',
updatedAt: '2025-09-11T02:33:10.293Z',
},
{
balance: 200,
createdAt: '2025-09-10T02:59:43.228Z',
id: 'b9f8eef6-5e8a-4e64-9e10-d0d6fabf05dc',
name: 'Альфа-Банк',
updatedAt: '2025-09-11T02:33:07.601Z',
},
];

View File

@ -0,0 +1,18 @@
export const cashList = [
{
balance: 41300,
createdAt: '2025-09-12T03:04:42.222Z',
id: '07d99f18-0618-444f-af5a-c5084d79bc31',
name: 'Аренда квартиры',
updatedAt: '2025-09-12T04:06:48.224Z',
userId: 'a94c46ad-64bb-4d6a-ba0f-85a466f57537',
},
{
balance: 19000,
createdAt: '2025-09-12T04:07:01.713Z',
id: 'f2d52442-c260-470c-9111-0da736f4daaf',
name: 'Свободные',
updatedAt: '2025-09-18T02:38:22.869Z',
userId: 'a94c46ad-64bb-4d6a-ba0f-85a466f57537',
},
];

View File

@ -0,0 +1,77 @@
export const items1 = [
{
amount: 500,
count: 1,
createdAt: '2024-04-01T12:00:00Z',
currency: 'RUB',
date: '2024-04-01',
description: 'Ежемесячная плата за интернет',
expenseListId: '1',
id: '1',
payment: null,
title: 'Счет за интернет',
updatedAt: '2024-04-01T12:00:00Z',
},
{
amount: 3000,
count: 1,
createdAt: '2024-04-02T12:00:00Z',
currency: 'RUB',
date: '2024-04-02',
description: 'Продукты на неделю',
expenseListId: '1',
id: '2',
payment: null,
title: 'Покупка продуктов',
updatedAt: '2024-04-02T12:00:00Z',
},
];
export const items2 = [];
export const items3 = null;
export const items4 = [
{
amount: 1500,
count: null,
createdAt: '2024-04-03T12:00:00Z',
currency: 'RUB',
date: '2024-04-03',
description: null,
expenseListId: '4',
id: '3',
payment: null,
title: 'Бензин',
updatedAt: '2024-04-03T12:00:00Z',
},
];
export const items5 = [
{
amount: 50000,
count: 1,
createdAt: '2024-04-04T12:00:00Z',
currency: 'RUB',
date: '2024-04-04',
description: 'Покупка нового ноутбука',
expenseListId: '5',
id: '4',
payment: null,
title: 'Ноутбук',
updatedAt: '2024-04-04T12:00:00Z',
},
{
amount: 3000,
count: 1,
createdAt: '2024-04-05T12:00:00Z',
currency: 'RUB',
date: '2024-04-05',
description: 'Механическая мышка',
expenseListId: '5',
id: '5',
payment: null,
title: 'Мышка',
updatedAt: '2024-04-05T12:00:00Z',
},
];

View File

@ -0,0 +1,47 @@
import {
items1,
items2,
items3,
items4,
items5,
} from './items';
export const list1 = {
createdAt: '2024-03-28T10:00:00Z',
id: '1',
items: items1,
title: 'Расходы на быт',
updatedAt: '2024-04-02T12:00:00Z',
};
export const list2 = {
createdAt: '2024-04-01T10:00:00Z',
id: '2',
items: items2,
title: 'Дополнительные расходы',
updatedAt: '2024-04-01T10:00:00Z',
};
export const list3 = {
createdAt: '2024-04-01T11:00:00Z',
id: '3',
items: items3,
title: 'Кредиты',
updatedAt: '2024-04-01T11:00:00Z',
};
export const list4 = {
createdAt: '2024-04-03T10:00:00Z',
id: '4',
items: items4,
title: 'Автомобиль',
updatedAt: '2024-04-03T10:00:00Z',
};
export const list5 = {
createdAt: '2024-04-04T10:00:00Z',
id: '5',
items: items5,
title: 'Техника',
updatedAt: '2024-04-05T10:00:00Z',
};

View File

@ -0,0 +1,2 @@
export { bankCardList } from './bankCards/list';
export { cashList } from './cash/list';

View File

@ -0,0 +1,9 @@
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
type LoginResponse = paths['/auth/login']['post']['responses']['200']['content']['application/json'];
export const loginHandler = http.post<never, never, LoginResponse>(
'/auth/login',
() => HttpResponse.json({ accessToken: '123' }),
);

View File

@ -0,0 +1,11 @@
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
import { bankCardList } from '../../../data/bankCards/list';
type BankCardsResponse = paths['/bank-card']['get']['responses']['200']['content']['application/json'];
export const getBankCardList = http.get<never, never, BankCardsResponse>(
'/bank-card',
() => HttpResponse.json(bankCardList),
);

View File

@ -0,0 +1,11 @@
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
import { cashList } from '../../../data/cash/list';
type CashResponse = paths['/cash']['get']['responses']['200']['content']['application/json'];
export const getCashList = http.get<never, never, CashResponse>(
'/cash',
() => HttpResponse.json(cashList),
);

View File

@ -0,0 +1,12 @@
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
type ExpensesListItemsResponse = paths['/expenses/list/{id}']['get']['responses']['200']['content']['application/json'];
export const getExpenseItemListById = (id: string) => http.get<never, never, ExpensesListItemsResponse>(
`/expenses/list/${id}`,
// @ts-ignore
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
async () => HttpResponse.json((await import('@mocks/browser/data/expenses/lists'))[`list${id}`]),
);

View File

@ -0,0 +1,24 @@
import {
list1,
list2,
list3,
list4,
list5,
} from '@mocks/browser/data/expenses/lists';
import { http, HttpResponse } from 'msw';
import type { paths } from '@/shared/api/schema';
const expenseLists = [
list1,
list2,
list3,
list4,
list5,
];
type ExpensesListResponse = paths['/expenses/list']['get']['responses']['200']['content']['application/json'];
export const getExpenseList = http.get<never, never, ExpensesListResponse>(
'/expenses/list',
() => HttpResponse.json(expenseLists),
);

View File

@ -0,0 +1,5 @@
export { loginHandler } from './auth/login/post';
export { getBankCardList } from './bank-cards/list/get';
export { getCashList } from './cash/list/get';
export { getExpenseItemListById } from './expense/list/:id/get';
export { getExpenseList } from './expense/list/get';

View File

@ -0,0 +1,17 @@
import { setupWorker } from 'msw/browser';
import {
getExpenseItemListById,
getExpenseList,
loginHandler,
} from './handlers';
export const worker = setupWorker(
loginHandler,
getExpenseList,
getExpenseItemListById('1'),
getExpenseItemListById('2'),
getExpenseItemListById('3'),
getExpenseItemListById('4'),
getExpenseItemListById('5'),
);

View File

@ -0,0 +1 @@
module.exports = 'test-file-stub';

View File

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

View File

@ -0,0 +1,16 @@
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
export const selectOption = async (selectElement: HTMLElement | null, value: string) => {
if (!selectElement) {
throw new Error('Не найден элемент выпадающего списка');
}
const bankcardDropdown = within(selectElement).getByRole('combobox');
await userEvent.click(bankcardDropdown);
const option = screen.getAllByRole('option').find((el) => el.dataset.value === value);
if (!option) {
throw new Error(`Не найдена опция с value:${value}`);
}
await userEvent.click(option);
};

View File

@ -0,0 +1,17 @@
import userEvent from '@testing-library/user-event';
export const typeDateField = async (dateElement: HTMLElement | null, value?: null | string) => {
if (!dateElement) {
throw new Error('Не найдено поле ввода');
}
if (value === null) {
await userEvent.clear(dateElement);
}
if (value) {
await userEvent.click(dateElement);
await userEvent.clear(dateElement);
await userEvent.type(dateElement, value);
}
};

View File

@ -0,0 +1,16 @@
import userEvent from '@testing-library/user-event';
export const typeTextField = async (inputElement: HTMLElement | null, value?: null | string) => {
if (!inputElement) {
throw new Error('Не найдено поле ввода');
}
if (value === null) {
await userEvent.clear(inputElement);
}
if (value) {
await userEvent.clear(inputElement);
await userEvent.type(inputElement, value);
}
};

View File

@ -0,0 +1,3 @@
import { setupServer } from 'msw/node';
export const server = setupServer();

View File

@ -0,0 +1,17 @@
import type { PropsWithChildren } from 'react';
import { createLocalizationWrapper } from './LocalizationWrapper';
import { createReactQueryWrapper } from './ReactQueryWrapper';
export const createAppWrapper = () => ({ children }: PropsWithChildren) => {
const ReactQueryWrapper = createReactQueryWrapper();
const LocalizationWrapper = createLocalizationWrapper();
return (
<ReactQueryWrapper>
<LocalizationWrapper>
{children}
</LocalizationWrapper>
</ReactQueryWrapper>
);
};

View File

@ -0,0 +1,11 @@
import type { PropsWithChildren } from 'react';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { ru } from 'date-fns/locale/ru';
export const createLocalizationWrapper = () => ({ children }: PropsWithChildren) => (
<LocalizationProvider adapterLocale={ru} dateAdapter={AdapterDateFns}>
{children}
</LocalizationProvider>
);

View File

@ -0,0 +1,19 @@
import type { PropsWithChildren } from 'react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
export const createReactQueryWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return ({ children }: PropsWithChildren) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
};

View File

@ -0,0 +1,3 @@
export { createAppWrapper } from './AppWrapper';
export { createLocalizationWrapper } from './LocalizationWrapper';
export { createReactQueryWrapper } from './ReactQueryWrapper';

View File

@ -0,0 +1,30 @@
// eslint-disable-next-line import/no-import-module-exports
import JSDOMEnvironment from 'jest-environment-jsdom';
class FixedJSDOMEnvironment extends JSDOMEnvironment {
constructor(...args: unknown[]) {
// @ts-ignore
super(...args);
this.global.TextDecoder = TextDecoder;
this.global.TextEncoder = TextEncoder;
this.global.TextDecoderStream = TextDecoderStream;
this.global.TextEncoderStream = TextEncoderStream;
this.global.ReadableStream = ReadableStream;
this.global.Blob = Blob;
this.global.Headers = Headers;
// this.global.FormData = FormData;
this.global.Request = Request;
this.global.Response = Response;
this.global.fetch = fetch;
this.global.structuredClone = structuredClone;
this.global.URL = URL;
this.global.URLSearchParams = URLSearchParams;
this.global.BroadcastChannel = BroadcastChannel;
this.global.TransformStream = TransformStream;
}
}
module.exports = FixedJSDOMEnvironment;

View File

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

View File

@ -0,0 +1,14 @@
import '@testing-library/jest-dom';
import { server } from '@mocks/jest/server';
import './setupEnv';
beforeAll(() => {
server.listen();
});
afterEach(() => {
server.resetHandlers();
});
afterAll(() => {
server.close();
});

View File

@ -1,84 +1,41 @@
import pluginQuery from '@tanstack/eslint-plugin-query'; import pluginQuery from '@tanstack/eslint-plugin-query';
// @ts-expect-error fix package // @ts-expect-error fix package
import config from 'eslint-config-ksv741'; import config from 'eslint-config-ksv741';
import perfectionist from 'eslint-plugin-perfectionist';
// eslint-disable-next-line import/no-anonymous-default-export
export default [ export default [
...pluginQuery.configs['flat/recommended'], ...pluginQuery.configs['flat/recommended'],
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
...config, ...config,
{ {
rules: { rules: {
'import/no-extraneous-dependencies': ['error', { '@stylistic/jsx-sort-props': 'off',
devDependencies: ['eslint.config.ts', 'rsbuild.config.ts'],
}],
'import/no-anonymous-default-export': 'off',
'import/order': [
'error', {
pathGroups: [
{
pattern: '__spec__/**',
group: 'builtin',
position: 'before',
},
{
pattern: 'apps/**',
group: 'internal',
position: 'after',
},
{
pattern: 'pages/**',
group: 'internal',
position: 'after',
},
{
pattern: 'widgets/**',
group: 'internal',
position: 'after',
},
{
pattern: 'features/**',
group: 'internal',
position: 'after',
},
{
pattern: 'entities/**',
group: 'internal',
position: 'after',
},
{
pattern: 'shared/**',
group: 'internal',
position: 'after',
},
],
distinctGroup: true,
groups: [
'builtin',
'external',
'internal',
'parent',
'sibling',
'object',
'index',
'type',
],
'newlines-between': 'always',
alphabetize: {
order: 'asc',
caseInsensitive: true,
},
},
],
'@typescript-eslint/strict-boolean-expressions': 'off',
'@typescript-eslint/no-floating-promises': 'off', '@typescript-eslint/no-floating-promises': 'off',
'@typescript-eslint/consistent-return': 'off', '@typescript-eslint/sort-type-constituents': 'off',
'@typescript-eslint/strict-boolean-expressions': 'off',
'func-style': 'off', 'func-style': 'off',
'@typescript-eslint/no-confusing-void-expression': 'off', 'import/no-extraneous-dependencies': ['error', {
'@typescript-eslint/await-thenable': 'off', devDependencies: [
'@typescript-eslint/no-misused-promises': 'off', 'eslint.config.ts',
'@typescript-eslint/prefer-nullish-coalescing': 'off', 'rsbuild.config.ts',
'configs/**/*',
'**/*.test.js',
'**/*.test.jsx',
'**/*.test.ts',
'**/*.test.tsx',
'**/__tests__/**',
'**/tests/**',
'__mocks__/**',
],
}],
'import/order': 'off',
'jsx-a11y/click-events-have-key-events': 'off',
'jsx-a11y/no-noninteractive-element-interactions': 'off',
'jsx-a11y/no-static-element-interactions': 'off',
}, },
}, },
perfectionist.configs['recommended-natural'],
{ {
ignores: ['./src/shared/api/schema.ts'], ignores: ['./src/shared/api/schema.ts'],
}, },

25
frontend/jest.config.ts Normal file
View File

@ -0,0 +1,25 @@
import type { Config } from 'jest';
export default {
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1',
'@mocks/(.*)': '<rootDir>/__mocks__/$1',
'@tests/(.*)': '<rootDir>/tests/$1',
'\\.(css|scss|sass|less)$': 'identity-obj-proxy',
'\\.(jpg|jpeg|png|gif|webp|svg)$': '<rootDir>/__mocks__/jest/fileMock.js',
},
setupFilesAfterEnv: ['<rootDir>/configs/jest/setupTests.ts'],
testEnvironment: '<rootDir>/configs/jest/jest-fixed-jsdom.ts',
transform: {
'^.+\\.(t|j)sx?$': ['@swc/jest', {
jsc: {
transform: {
react: {
runtime: 'automatic',
},
},
},
}],
},
transformIgnorePatterns: ['node_modules/(?!(until-async)/)'],
} as Config;

File diff suppressed because it is too large Load Diff

View File

@ -6,30 +6,57 @@
"scripts": { "scripts": {
"build": "rsbuild build", "build": "rsbuild build",
"dev": "rsbuild dev", "dev": "rsbuild dev",
"dev:mock": "rsbuild dev --env-mode mock",
"preview": "rsbuild preview", "preview": "rsbuild preview",
"lint": "eslint", "lint": "eslint --concurrency=auto",
"test:unit": "jest --verbose --silent",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"openapi:gen": "npx openapi-typescript http://localhost:3000/docs/swagger.json -o src/shared/api/schema.ts" "openapi:gen": "openapi-typescript http://localhost:3000/docs/swagger.json -o src/shared/api/schema.ts"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "5.85.5", "@emotion/react": "11.14.0",
"axios": "1.11.0", "@emotion/styled": "11.14.1",
"react": "19.1.1", "@fontsource/roboto": "5.2.8",
"react-dom": "19.1.1", "@mui/icons-material": "7.3.4",
"react-router": "7.8.1" "@mui/material": "7.3.4",
"@mui/x-date-pickers": "8.12.0",
"@tanstack/react-query": "5.90.2",
"axios": "1.12.2",
"date-fns": "4.1.0",
"date-fns-tz": "3.2.0",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-router": "7.9.4"
}, },
"devDependencies": { "devDependencies": {
"@rsbuild/core": "1.4.15", "@rsbuild/core": "1.5.14",
"@rsbuild/plugin-eslint": "1.1.2", "@rsbuild/plugin-eslint": "1.1.2",
"@rsbuild/plugin-react": "1.3.5", "@rsbuild/plugin-react": "1.4.1",
"@rsbuild/plugin-type-check": "1.2.4", "@rsbuild/plugin-type-check": "1.2.4",
"@tanstack/eslint-plugin-query": "5.83.1", "@swc/core": "1.13.5",
"@types/react": "19.1.9", "@swc/jest": "0.2.39",
"@types/react-dom": "19.1.7", "@tanstack/eslint-plugin-query": "5.91.0",
"@typescript-eslint/parser": "8.39.0", "@testing-library/dom": "10.4.1",
"eslint": "9.33.0", "@testing-library/jest-dom": "6.9.1",
"eslint-config-ksv741": "0.2.0", "@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "30.0.0",
"@types/react": "19.2.2",
"@types/react-dom": "19.2.1",
"eslint": "9.37.0",
"eslint-config-ksv741": "0.3.0",
"eslint-plugin-perfectionist": "4.15.1",
"identity-obj-proxy": "3.0.0",
"jest": "30.2.0",
"jest-environment-jsdom": "30.2.0",
"msw": "2.11.3",
"openapi-typescript": "7.9.1", "openapi-typescript": "7.9.1",
"typescript": "5.9.2" "ts-node": "10.9.2",
"typescript": "5.9.3"
},
"msw": {
"workerDirectory": [
"public"
]
} }
} }

View File

@ -0,0 +1,348 @@
/* eslint-disable */
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.11.3'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@ -4,11 +4,16 @@ import { pluginReact } from '@rsbuild/plugin-react';
import { pluginTypeCheck } from '@rsbuild/plugin-type-check'; import { pluginTypeCheck } from '@rsbuild/plugin-type-check';
export default defineConfig({ export default defineConfig({
html: {
title: 'Investments Control',
},
plugins: [ plugins: [
pluginReact(), pluginReact(),
pluginEslint({ pluginEslint({
eslintPluginOptions: { eslintPluginOptions: {
configType: 'flat', configType: 'flat',
emitError: false,
emitWarning: false,
failOnError: false, failOnError: false,
failOnWarning: false, failOnWarning: false,
}, },
@ -17,10 +22,12 @@ export default defineConfig({
], ],
server: { server: {
proxy: { proxy: {
'/api': { [`${process.env.PUBLIC_API_BASE_URL}`]: {
target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
pathRewrite: { '^/api': '' }, pathRewrite: {
[`^${process.env.PUBLIC_API_BASE_URL}`]: '',
},
target: process.env.BACKEND_BASE_URL,
}, },
}, },
}, },

View File

@ -1,34 +1,21 @@
import './normalize.css'; import './normalize.css';
import { LocalizationProvider } from '@mui/x-date-pickers';
import { AdapterDateFns } from '@mui/x-date-pickers/AdapterDateFns';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { import { ru } from 'date-fns/locale/ru';
BrowserRouter,
Route,
Routes,
} from 'react-router';
import { LoginPage, RegisterPage } from '@/pages/Authorization';
import { HomePage } from '@/pages/Home';
import { AuthContextProvider } from '@/shared/context/AuthContext'; import { AuthContextProvider } from '@/shared/context/AuthContext';
import { AuthRoute } from '@/shared/ui/AuthRoute';
import Routing from './Routing';
const queryClient = new QueryClient(); const queryClient = new QueryClient();
const App = () => ( const App = () => (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthContextProvider> <AuthContextProvider>
<BrowserRouter> <LocalizationProvider adapterLocale={ru} dateAdapter={AdapterDateFns}>
<Routes> <Routing />
<Route element={<AuthRoute />}> </LocalizationProvider>
<Route element={<HomePage />} path="/" />
</Route>
<Route element={<AuthRoute needAuth={false} to="/" />}>
<Route element={<LoginPage />} path="/login" />
<Route element={<RegisterPage />} path="/register" />
</Route>
</Routes>
</BrowserRouter>
</AuthContextProvider> </AuthContextProvider>
</QueryClientProvider> </QueryClientProvider>
); );

View File

@ -0,0 +1,42 @@
import { BrowserRouter, Route, Routes } from 'react-router';
import { LoginPage, RegisterPage } from '@/pages/Authorization';
import { BankCardsPage } from '@/pages/BankCard';
import { CashPage } from '@/pages/Cash';
import { ExpensePage } from '@/pages/Expense';
import { ExpensesPage } from '@/pages/Expenses';
import { HomePage } from '@/pages/Home';
import { IncomePage } from '@/pages/Income';
import { IncomesPage } from '@/pages/Incomes';
import { PAGES } from '@/shared/router';
import { AuthRoute } from '@/shared/ui/AuthRoute';
const Routing = () => (
<BrowserRouter>
<Routes>
<Route element={<AuthRoute />}>
<Route element={<HomePage />} path={PAGES.Home} />
<Route element={<ExpensesPage />} path={PAGES.Expenses} />
<Route element={<ExpensePage />} path={PAGES.Expense} />
<Route element={<IncomesPage />} path={PAGES.Incomes} />
<Route element={<IncomePage />} path={PAGES.Income} />
<Route element={<BankCardsPage />} path={PAGES.BankCards} />
<Route element={<CashPage />} path={PAGES.Cash} />
</Route>
<Route element={<AuthRoute needAuth={false} to="/" />}>
<Route element={<LoginPage />} path={PAGES.Login} />
<Route element={<RegisterPage />} path={PAGES.Register} />
</Route>
</Routes>
</BrowserRouter>
);
export default Routing;

View File

@ -347,3 +347,7 @@ template {
[hidden] { [hidden] {
display: none; display: none;
} }
* {
box-sizing: border-box;
}

View File

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

View File

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

View File

@ -1,22 +1,12 @@
import { useMutation } from '@tanstack/react-query'; import type { paths } from '@/shared/api/schema';
import { api } from '@/shared/api/client'; import { api } from '@/shared/api/client';
import type { paths } from '@/shared/api/schema';
type RegisterRequestBody = paths['/auth/register']['post']['requestBody']['content']['application/json']; type RegisterRequestBody = paths['/auth/register']['post']['requestBody']['content']['application/json'];
type RegisterSuccessResponse = paths['/auth/register']['post']['responses']['200']['content']['application/json']; type RegisterSuccessResponse = paths['/auth/register']['post']['responses']['200']['content']['application/json'];
async function register(data: RegisterRequestBody) { export async function register(data: RegisterRequestBody) {
const { data: result } = await api.post<RegisterSuccessResponse>('auth/register', data); const { data: result } = await api.post<RegisterSuccessResponse>('auth/register', data);
return result; return result;
} }
export const useRegister = () => useMutation({
mutationFn: register,
onError: (error) => {
// eslint-disable-next-line no-console
console.log('Неуспешная регистрация', error);
},
});

View File

@ -2,19 +2,10 @@ import { useMutation } from '@tanstack/react-query';
import { useContext } from 'react'; import { useContext } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { api } from '@/shared/api/client';
import { AuthContext } from '@/shared/context/AuthContext'; import { AuthContext } from '@/shared/context/AuthContext';
import { PAGES } from '@/shared/router';
import type { paths } from '@/shared/api/schema'; import { login } from '../api/login';
type LoginRequestBody = paths['/auth/login']['post']['requestBody']['content']['application/json'];
type LoginSuccessResponse = paths['/auth/login']['post']['responses']['200']['content']['application/json'];
async function login(data: LoginRequestBody): Promise<LoginSuccessResponse> {
const { data: result } = await api.post<LoginSuccessResponse>('/auth/login', data);
return result;
}
export const useLogin = () => { export const useLogin = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -22,16 +13,16 @@ export const useLogin = () => {
return useMutation({ return useMutation({
mutationFn: login, mutationFn: login,
onSuccess: async (data) => {
setAccessToken(data.accessToken);
localStorage.setItem('accessToken', data.accessToken);
await navigate('/another');
},
onError: (error) => { onError: (error) => {
// eslint-disable-next-line // eslint-disable-next-line
console.log('Неуспешный логин', error); console.log('Неуспешный логин', error);
throw error; throw error;
}, },
onSuccess: async (data) => {
setAccessToken(data.accessToken);
localStorage.setItem('accessToken', data.accessToken);
await navigate(PAGES.Home);
},
}); });
}; };

View File

@ -2,18 +2,10 @@ import { useMutation } from '@tanstack/react-query';
import { useContext } from 'react'; import { useContext } from 'react';
import { useNavigate } from 'react-router'; import { useNavigate } from 'react-router';
import { api } from '@/shared/api/client';
import { AuthContext } from '@/shared/context/AuthContext'; import { AuthContext } from '@/shared/context/AuthContext';
import { PAGES } from '@/shared/router';
import type { paths } from '@/shared/api/schema'; import { logout } from '../api/logout';
type LogoutSuccessResponse = paths['/auth/logout']['post']['responses']['200']['content']['application/json'];
async function logout() {
const { data: result } = await api.post<LogoutSuccessResponse>('/auth/logout');
return result;
}
export const useLogout = () => { export const useLogout = () => {
const navigate = useNavigate(); const navigate = useNavigate();
@ -24,7 +16,7 @@ export const useLogout = () => {
onSuccess: async () => { onSuccess: async () => {
setAccessToken(''); setAccessToken('');
localStorage.removeItem('accessToken'); localStorage.removeItem('accessToken');
await navigate('/login'); await navigate(PAGES.Login);
}, },
}); });
}; };

View File

@ -0,0 +1,11 @@
import { useMutation } from '@tanstack/react-query';
import { register } from '../api/register';
export const useRegister = () => useMutation({
mutationFn: register,
onError: (error) => {
// eslint-disable-next-line no-console
console.log('Неуспешная регистрация', error);
},
});

View File

@ -1,3 +1,3 @@
export { useLogin } from './login/useLogin'; export { useLogin } from './hooks/useLogin';
export { useRegister } from './register/useRegister'; export { useLogout } from './hooks/useLogout';
export { useLogout } from './logout/useLogout'; export { useRegister } from './hooks/useRegister';

View File

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

View File

@ -0,0 +1,11 @@
import type { paths } from '@/shared/api/schema';
import { api } from '@/shared/api/client';
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,19 @@
export type { AddBankCardBody, AddBankCardResponse } from './api/addBankCard';
export type { DeleteBankCardPath, DeleteBankCardResponse } from './api/deleteBankCard';
export type { EditBankCardBody, EditBankCardPath, EditBankCardResponse } from './api/editBankCard';
export { useAddBankCard } from './hooks/useAddBankCard';
export { useBankCards } from './hooks/useBankCards';
export { useDeleteBankCard } from './hooks/useDeleteBankCard';
export { useEditBankCard } from './hooks/useEditBankCard';
export type { BankCard, BankCards } from './types';
export type { AddBankCardFormProps, AddBankCardFormValues } from './ui/AddBankCardForm';
export { AddBankCardForm } from './ui/AddBankCardForm';
export type { DeleteBankCardFormProps } from './ui/DeleteBankCardForm';
export { DeleteBankCardForm } from './ui/DeleteBankCardForm';
export type { EditBankCardFormProps, EditBankCardFormValues } from './ui/EditBankCardForm';
export { EditBankCardForm } from './ui/EditBankCardForm';

View File

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

View File

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

View File

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

View File

@ -0,0 +1,34 @@
import { AddBankCardFormHelper } from '@tests/helpers';
describe('Test AddBankCardForm', () => {
test('should render form', () => {
AddBankCardFormHelper.renderForm();
const { formElem } = AddBankCardFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
AddBankCardFormHelper.renderForm(options);
const { formElem, ...formElems } = AddBankCardFormHelper.getFormElements();
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const { onSubmit } = AddBankCardFormHelper.renderForm();
await AddBankCardFormHelper.fillForm({
balance: 500,
name: 'Тестовая карта',
});
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
balance: 500,
name: 'Тестовая карта',
});
});
});

View File

@ -0,0 +1,93 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import { Button, TextField, Typography } from '@mui/material';
import { DataTestId } from '@/shared/constants';
import classes from './AddBankCardForm.module.css';
export type AddBankCardFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddBankCardFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
export type AddBankCardFormValues = {
balance: number;
name: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const AddBankCardForm: FC<AddBankCardFormProps> = (props) => {
const {
disabled,
onSubmit,
ref,
...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?.({
balance: Number(balance),
name,
});
}
};
return (
<form
className={classes.form}
data-testid={DataTestId.ADD_BANK_CARD_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...formProps}
>
<Typography variant="h6">
Добавить карту
</Typography>
<TextField
disabled={disabled}
label="Название"
name="name"
required
size="small"
/>
<TextField
defaultValue={0}
disabled={disabled}
inputMode="numeric"
label="Баланс"
name="balance"
size="small"
/>
<Button
data-testid={DataTestId.ADD_BANK_CARD_SUBMIT_BUTTON}
disabled={disabled}
sx={{ mt: 2 }}
type="submit"
variant="contained"
>
Добавить
</Button>
</form>
);
};
export default AddBankCardForm;

View File

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

View File

@ -0,0 +1,18 @@
import { DeleteBankCardFormHelper } from '@tests/helpers';
describe('Test DeleteExpensesListForm', () => {
test('should render form', () => {
DeleteBankCardFormHelper.renderForm();
const { formElem } = DeleteBankCardFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test('should call onSubmit with values', async () => {
const { onSubmit } = DeleteBankCardFormHelper.renderForm();
await DeleteBankCardFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(DeleteBankCardFormHelper.bankCard);
});
});

View File

@ -0,0 +1,75 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import { Button, Typography } from '@mui/material';
import { DataTestId } from '@/shared/constants';
import type { BankCard } from '../../../types';
export type DeleteBankCardFormProps = BaseFormProps & {
bankCard: BankCard;
disabled?: boolean;
onSubmit?: (bankCard: BankCard) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const DeleteBankCardForm: FC<DeleteBankCardFormProps> = (props) => {
const {
bankCard,
disabled,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(bankCard);
};
return (
<form
data-testid={DataTestId.DELETE_BANK_CARD_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...restProps}
>
<Typography variant="h6">
Удалить список расходов
</Typography>
<Typography variant="body1">
Вы действительно хотите удалить банковскую карту
{' '}
<b>
{`"${bankCard.name}"`}
</b>
{' '}
?
</Typography>
<Button
color="error"
data-testid={DataTestId.DELETE_BANK_CARD_SUBMIT_BUTTON}
disabled={disabled}
fullWidth
sx={{ mt: 2 }}
type="submit"
variant="contained"
>
Удалить
</Button>
</form>
);
};
export default DeleteBankCardForm;

View File

@ -0,0 +1,3 @@
export type { EditBankCardFormValues } from './ui/EditBankCardForm.ui';
export type { EditBankCardFormProps } 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,38 @@
import { EditBankCardFormHelper } from '@tests/helpers';
describe('Test EditBankCardForm', () => {
test('should render form', () => {
EditBankCardFormHelper.renderForm();
const { formElem } = EditBankCardFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test('should inputs have default value', () => {
EditBankCardFormHelper.renderForm();
const { balanceInputElem, nameInputElem } = EditBankCardFormHelper.getFormElements();
expect(nameInputElem).toHaveValue('Сбербанк');
expect(balanceInputElem).toHaveValue('0');
});
test.each([
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
EditBankCardFormHelper.renderForm(options);
const { formElem, ...formElems } = EditBankCardFormHelper.getFormElements();
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const { onSubmit } = EditBankCardFormHelper.renderForm();
const data = {
balance: 5000,
name: 'Новое название карты',
};
await EditBankCardFormHelper.fillForm(data);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(data);
});
});

View File

@ -0,0 +1,98 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import { Button, TextField, Typography } from '@mui/material';
import type { BankCard } from '@/entity/cards';
import { DataTestId } from '@/shared/constants';
import classes from './EditBankCardForm.module.css';
export type EditBankCardFormProps = BaseFormProps & {
bankCard: BankCard;
disabled?: boolean;
onSubmit?: (data: EditBankCardFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
export type EditBankCardFormValues = {
balance: number;
name: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
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?.({
balance: Number(balance),
name,
});
}
};
return (
<form
className={classes.form}
data-testid={DataTestId.EDIT_BANK_CARD_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...restProps}
>
<Typography variant="h6">
Добавить карту
</Typography>
<TextField
defaultValue={bankCard.name}
disabled={disabled}
label="Название"
name="name"
required
size="small"
/>
<TextField
defaultValue={bankCard.balance}
disabled={disabled}
inputMode="numeric"
label="Баланс"
name="balance"
size="small"
/>
<Button
data-testid={DataTestId.EDIT_BANK_CARD_SUBMIT_BUTTON}
disabled={disabled}
fullWidth
type="submit"
variant="contained"
>
Сохранить
</Button>
</form>
);
};
export default EditBankCardForm;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,19 @@
export type { AdCashResponse, AddCashBody } from './api/addCash';
export type { DeleteCashPath, DeleteCashResponse } from './api/deleteCash';
export type { EditCashBody, EditCashPath, EditCashResponse } from './api/editCash';
export { useAddCash } from './hooks/useAddCash';
export { useCash } from './hooks/useCash';
export { useDeleteCash } from './hooks/useDeleteCash';
export { useEditCash } from './hooks/useEditCash';
export type { CashItem, CashList } from './types';
export { AddCashForm } from './ui/AddCashForm';
export type { AddCashFormProps, AddCashFormValues } from './ui/AddCashForm';
export type { DeleteCashFormProps } from './ui/DeleteCashForm';
export { DeleteCashForm } from './ui/DeleteCashForm';
export { EditCashForm } from './ui/EditCashForm';
export type { EditCashFormProps, EditCashFormValues } from './ui/EditCashForm';

View File

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

View File

@ -0,0 +1,2 @@
export type { AddCashFormProps, AddCashFormValues } from './ui/AddCashForm.ui';
export { default as AddCashForm } from './ui/AddCashForm.ui';

View File

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

View File

@ -0,0 +1,28 @@
import { AddCashFormHelper } from '@tests/helpers';
describe('Test AddCashForm', () => {
test('should render form', () => {
AddCashFormHelper.renderForm();
const { formElem } = AddCashFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
AddCashFormHelper.renderForm(options);
const { formElem, ...formElems } = AddCashFormHelper.getFormElements();
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const { onSubmit } = AddCashFormHelper.renderForm();
const data = { balance: 500, name: 'Наличные' };
await AddCashFormHelper.fillForm(data);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(data);
});
});

View File

@ -0,0 +1,92 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import { Button, TextField, Typography } from '@mui/material';
import { DataTestId } from '@/shared/constants';
import classes from './AddCashForm.module.css';
export type AddCashFormProps = BaseFormProps & {
disabled?: boolean;
onSubmit?: (data: AddCashFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
export type AddCashFormValues = {
balance: number;
name: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const AddCashForm: FC<AddCashFormProps> = (props) => {
const {
disabled,
onSubmit,
ref,
...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?.({
balance: Number(balance),
name,
});
}
};
return (
<form
className={classes.form}
data-testid={DataTestId.ADD_CASH_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...formProps}
>
<Typography variant="h6">
Добавить наличные
</Typography>
<TextField
disabled={disabled}
label="Название"
name="name"
required
size="small"
/>
<TextField
defaultValue={0}
disabled={disabled}
inputMode="numeric"
label="Баланс"
name="balance"
size="small"
/>
<Button
data-testid={DataTestId.ADD_CASH_SUBMIT_BUTTON}
disabled={disabled}
type="submit"
variant="contained"
>
Добавить
</Button>
</form>
);
};
export default AddCashForm;

View File

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

View File

@ -0,0 +1,19 @@
import { DeleteCashFormHelper } from '@tests/helpers';
describe('Test DeleteCashForm', () => {
test('should render form', () => {
DeleteCashFormHelper.renderForm();
const { formElem } = DeleteCashFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test('should call onSubmit with values', async () => {
const { onSubmit } = DeleteCashFormHelper.renderForm();
await DeleteCashFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(DeleteCashFormHelper.cash);
});
});

View File

@ -0,0 +1,75 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import { Button, Typography } from '@mui/material';
import { DataTestId } from '@/shared/constants';
import type { CashItem } from '../../../types';
export type DeleteCashFormProps = BaseFormProps & {
cash: CashItem;
disabled?: boolean;
onSubmit?: (cash: CashItem) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const DeleteCashForm: FC<DeleteCashFormProps> = (props) => {
const {
cash,
disabled,
onSubmit,
ref,
...restProps
} = props;
const onFormSubmitHandler: FormEventHandler<HTMLFormElement> = (event) => {
event.preventDefault();
onSubmit?.(cash);
};
return (
<form
data-testid={DataTestId.DELETE_CASH_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...restProps}
>
<Typography variant="h6">
Удалить наличные
</Typography>
<Typography variant="body1">
Вы действительно хотите удалить наличные
{' '}
<b>
{`"${cash.name}"`}
</b>
{' '}
?
</Typography>
<Button
color="error"
data-testid={DataTestId.DELETE_CASH_SUBMIT_BUTTON}
disabled={disabled}
fullWidth
sx={{ mt: 2 }}
type="submit"
variant="contained"
>
Удалить
</Button>
</form>
);
};
export default DeleteCashForm;

View File

@ -0,0 +1,2 @@
export type { EditCashFormProps, EditCashFormValues } from './ui/EditCashForm.ui';
export { default as EditCashForm } from './ui/EditCashForm.ui';

View File

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

View File

@ -0,0 +1,35 @@
import { EditCashFormHelper } from '@tests/helpers';
describe('Test EditCashForm', () => {
test('should render form', () => {
EditCashFormHelper.renderForm();
const { formElem } = EditCashFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test('should inputs have default value', () => {
EditCashFormHelper.renderForm();
const { balanceInputElem, nameInputElem } = EditCashFormHelper.getFormElements();
expect(nameInputElem).toHaveValue('Аренда квартиры');
expect(balanceInputElem).toHaveValue('41300');
});
test.each([
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
EditCashFormHelper.renderForm(options);
const { formElem, ...formElems } = EditCashFormHelper.getFormElements();
Object.values(formElems).forEach(assertion);
});
test('should call onSubmit with values', async () => {
const { onSubmit } = EditCashFormHelper.renderForm();
const data = { balance: 5000, name: 'Новое название наличных' };
await EditCashFormHelper.fillForm(data);
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(data);
});
});

View File

@ -0,0 +1,97 @@
import type {
FC,
FormEventHandler,
FormHTMLAttributes,
RefObject,
} from 'react';
import { Button, TextField, Typography } from '@mui/material';
import type { CashItem } from '@/entity/cash';
import { DataTestId } from '@/shared/constants';
import classes from './EditCashForm.module.css';
export type EditCashFormProps = BaseFormProps & {
cash: CashItem;
disabled?: boolean;
onSubmit?: (data: EditCashFormValues) => Promise<void> | void;
ref?: RefObject<HTMLFormElement>;
};
export type EditCashFormValues = {
balance: number;
name: string;
};
type BaseFormProps = Omit<FormHTMLAttributes<HTMLFormElement>, 'onSubmit'>;
const EditCashForm: FC<EditCashFormProps> = (props) => {
const {
cash,
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?.({
balance: Number(balance),
name,
});
}
};
return (
<form
className={classes.form}
data-testid={DataTestId.EDIT_CASH_FORM}
onSubmit={onFormSubmitHandler}
ref={ref}
{...restProps}
>
<Typography variant="h6">
Редактировать наличные
</Typography>
<TextField
defaultValue={cash.name}
disabled={disabled}
label="Название"
name="name"
required
size="small"
/>
<TextField
defaultValue={cash.balance}
disabled={disabled}
inputMode="numeric"
label="Баланс"
name="balance"
size="small"
/>
<Button
data-testid={DataTestId.EDIT_CASH_SUBMIT_BUTTON}
disabled={disabled}
type="submit"
variant="contained"
>
Сохранить
</Button>
</form>
);
};
export default EditCashForm;

View File

@ -0,0 +1,13 @@
import type { paths } from '@/shared/api/schema';
import { api } from '@/shared/api/client';
export type AddExpenseItemBody = paths['/expenses/list/{id}/items']['post']['requestBody']['content']['application/json'];
export type AddExpenseItemPath = paths['/expenses/list/{id}/items']['post']['parameters']['path'];
export type AddExpenseItemSuccessResponse = paths['/expenses/list/{id}/items']['post']['responses']['200']['content']['application/json'];
export async function addExpenseItem(path: AddExpenseItemPath, data: AddExpenseItemBody) {
const { data: result } = await api.post<AddExpenseItemSuccessResponse>(`/expenses/list/${path.id}/items`, data);
return result;
}

View File

@ -0,0 +1,12 @@
import type { paths } from '@/shared/api/schema';
import { api } from '@/shared/api/client';
export type DeleteExpenseItemPath = paths['/expenses/list/{listId}/items/{id}']['delete']['parameters']['path'];
export type DeleteExpenseItemSuccessResponse = paths['/expenses/list/{id}/items']['post']['responses']['200']['content']['application/json'];
export async function deleteExpenseItem(path: DeleteExpenseItemPath) {
const { data: result } = await api.delete<DeleteExpenseItemSuccessResponse>(`/expenses/list/${path.listId}/items/${path.id}`);
return result;
}

View File

@ -0,0 +1,13 @@
import type { paths } from '@/shared/api/schema';
import { api } from '@/shared/api/client';
export type EditExpenseItemBody = paths['/expenses/list/{listId}/items/{id}']['patch']['requestBody']['content']['application/json'];
export type EditExpenseItemPath = paths['/expenses/list/{listId}/items/{id}']['patch']['parameters']['path'];
export type EditExpenseItemSuccessResponse = paths['/expenses/list/{listId}/items/{id}']['patch']['responses']['200']['content']['application/json'];
export async function editExpenseItem(path: EditExpenseItemPath, data: EditExpenseItemBody) {
const { data: result } = await api.patch<EditExpenseItemSuccessResponse>(`/expenses/list/${path.listId}/items/${path.id}`, data);
return result;
}

View File

@ -0,0 +1,17 @@
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) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: AddExpenseItemBody) => addExpenseItem(path, data),
mutationKey: [QueryKeys.ExpenseList, path.id],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.id] });
},
});
};

View File

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

View File

@ -0,0 +1,17 @@
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) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async (data: EditExpenseItemBody) => editExpenseItem(path, data),
mutationKey: [QueryKeys.ExpenseList, path.listId],
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: [QueryKeys.ExpenseList, path.listId] });
},
});
};

View File

@ -0,0 +1,18 @@
export type { AddExpenseItemBody, AddExpenseItemPath, AddExpenseItemSuccessResponse } from './api/addExpenseItem';
export type { DeleteExpenseItemPath, DeleteExpenseItemSuccessResponse } from './api/deleteExpenseItem';
export type { EditExpenseItemBody, EditExpenseItemPath, EditExpenseItemSuccessResponse } from './api/editExpenseItem';
export { useAddExpenseItem } from './hooks/useAddExpenseItem';
export { useDeleteExpenseItem } from './hooks/useDeleteExpenseItem';
export { useEditExpenseItem } from './hooks/useEditExpenseItem';
export type { ExpenseListItem } from './types';
export type { AddExpenseItemFormProps, AddExpenseItemFormValues } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
export { default as AddExpenseItemForm } from './ui/AddExpenseItemForm/AddExpenseItemForm.ui';
export type { DeleteExpenseItemFormProps } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
export { default as DeleteExpenseItemForm } from './ui/DeleteExpenseItemForm/DeleteExpenseItemForm.ui';
export type { EditExpenseItemFormProps, EditExpenseItemFormValues } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';
export { default as EditExpenseItemForm } from './ui/EditExpenseItemForm/EditExpenseItemForm.ui';

View File

@ -0,0 +1,3 @@
import type { paths } from '@/shared/api/schema';
export type ExpenseListItem = NonNullable<paths['/expenses/list/{id}']['get']['responses']['200']['content']['application/json']['items']>[number];

View File

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

View File

@ -0,0 +1,121 @@
import { bankCardList, cashList } from '@mocks/browser/data';
import { within } from '@testing-library/react';
import { AddExpenseItemFormHelper } from '@tests/helpers';
describe('Test AddExpenseItemForm', () => {
test('should render form', () => {
AddExpenseItemFormHelper.renderForm();
const { formElem } = AddExpenseItemFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test.each([
['enabled', {}, (el: HTMLElement) => { expect(el).toBeEnabled(); }],
['disabled', { disabled: true }, (el: HTMLElement) => { expect(el).toBeDisabled(); }],
])('should render fields %s', (_state, options, assertion) => {
AddExpenseItemFormHelper.renderForm(options);
const {
bankCardSelect,
bankCardSelectInput,
cashSelect,
cashSelectInput,
formElem,
paymentTypeSelect,
...formElems
} = AddExpenseItemFormHelper.getFormElements();
[
...Object.values(formElems),
within(paymentTypeSelect).getByTestId('payment-type-select-input'),
].forEach(assertion);
});
describe('should call onSubmit with values', () => {
const setup = async () => {
const { onSubmit } = AddExpenseItemFormHelper.renderForm();
await AddExpenseItemFormHelper.fillForm({
amount: '12',
count: '1',
date: '29.08.2025',
description: 'Описание элемента',
title: 'Название элемента',
}, { autoSubmit: false });
return {
onSubmit,
};
};
test('no payment', async () => {
const { onSubmit } = await setup();
await AddExpenseItemFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: null,
title: 'Название элемента',
});
});
test('bank card payment', async () => {
const { onSubmit } = await setup();
await AddExpenseItemFormHelper.fillForm({ paymentType: 'BANK_CARD' }, { autoSubmit: false });
const { bankCardSelect, bankCardSelectInput } = AddExpenseItemFormHelper.getFormElements();
expect(bankCardSelect).toBeVisible();
expect(bankCardSelectInput).toBeEnabled();
expect(bankCardSelectInput).toHaveValue(bankCardList[0].id);
await AddExpenseItemFormHelper.fillForm({ paymentId: bankCardList[1].id, paymentType: 'BANK_CARD' }, { autoSubmit: false });
expect(bankCardSelectInput).toHaveValue(bankCardList[1].id);
await AddExpenseItemFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: {
id: bankCardList[1].id,
type: 'BANK_CARD',
},
title: 'Название элемента',
});
});
test('cash payment', async () => {
const { onSubmit } = await setup();
await AddExpenseItemFormHelper.fillForm({ paymentType: 'CASH' });
const { cashSelect, cashSelectInput } = AddExpenseItemFormHelper.getFormElements();
expect(cashSelect).toBeVisible();
expect(cashSelectInput).toBeEnabled();
expect(cashSelectInput).toHaveValue(cashList[0].id);
await AddExpenseItemFormHelper.fillForm({ paymentId: cashList[1].id, paymentType: 'CASH' });
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith({
amount: 12,
count: 1,
currency: 'RUB',
date: '2025-08-29T00:00:00.000Z',
description: 'Описание элемента',
payment: {
id: cashList[1].id,
type: 'CASH',
},
title: 'Название элемента',
});
});
});
});

View File

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

View File

@ -0,0 +1,18 @@
import { DeleteExpenseItemFormHelper } from '@tests/helpers';
describe('Test DeleteExpenseItemForm', () => {
test('should render form', () => {
DeleteExpenseItemFormHelper.renderForm();
const { formElem } = DeleteExpenseItemFormHelper.getFormElements();
expect(formElem).toBeInTheDocument();
expect(formElem).toBeVisible();
});
test('should call onSubmit with values', async () => {
const { onSubmit } = DeleteExpenseItemFormHelper.renderForm();
await DeleteExpenseItemFormHelper.submitForm();
expect(onSubmit).toHaveBeenCalledTimes(1);
expect(onSubmit).toHaveBeenCalledWith(DeleteExpenseItemFormHelper.expenseItem);
});
});

Some files were not shown because too many files have changed in this diff Show More