add first services
This commit is contained in:
32
.eslintrc.cjs
Normal file
32
.eslintrc.cjs
Normal file
@ -0,0 +1,32 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2021: true, jest: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
'plugin:jsx-a11y/recommended',
|
||||
'prettier', // Важно: prettier должен быть последним
|
||||
],
|
||||
parser: '@typescript-eslint/parser',
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
sourceType: 'module',
|
||||
project: './tsconfig.json', // Для правил, требующих информацию о типах
|
||||
},
|
||||
plugins: ['react', '@typescript-eslint', 'jsx-a11y'],
|
||||
settings: {
|
||||
react: {
|
||||
version: 'detect', // Автоматически определять версию React
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Отключаем правило, которое не нужно с новым JSX-трансформером
|
||||
'react/react-in-jsx-scope': 'off',
|
||||
// Мы используем TypeScript, поэтому prop-types не нужны
|
||||
'react/prop-types': 'off',
|
||||
// Пример кастомного правила: требовать явное указание возвращаемого типа функции
|
||||
'@typescript-eslint/explicit-function-return-type': 'warn',
|
||||
},
|
||||
};
|
||||
7
.prettierrc
Normal file
7
.prettierrc
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2
|
||||
}
|
||||
23
jest.config.ts
Normal file
23
jest.config.ts
Normal file
@ -0,0 +1,23 @@
|
||||
export default {
|
||||
// Указываем пресет для работы с TypeScript
|
||||
preset: 'ts-jest',
|
||||
|
||||
// Указываем тестовую среду, эмулирующую DOM
|
||||
testEnvironment: 'jest-environment-jsdom',
|
||||
|
||||
// Путь к файлу с глобальными настройками для тестов
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
|
||||
// Трансформер для файлов TypeScript
|
||||
transform: {
|
||||
'^.+\\.tsx?$': 'ts-jest',
|
||||
},
|
||||
|
||||
// Маппинг для обработки импортов, которые Jest не понимает
|
||||
moduleNameMapper: {
|
||||
// Мокируем импорты стилей
|
||||
'\\.(css|less|scss|sass)$': 'identity-obj-proxy',
|
||||
// Настраиваем алиас '@' так же, как в Vite и tsconfig
|
||||
'^@/(.*)$': '<rootDir>/src/$1',
|
||||
},
|
||||
};
|
||||
3
jest.setup.ts
Normal file
3
jest.setup.ts
Normal file
@ -0,0 +1,3 @@
|
||||
// Расширяет ожидания Jest с помощью удобных матчеров для DOM
|
||||
// Например, позволяет писать expect(element).toBeInTheDocument()
|
||||
import '@testing-library/jest-dom';
|
||||
10076
package-lock.json
generated
Normal file
10076
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
package.json
Normal file
55
package.json
Normal file
@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "task-tracker-for-students",
|
||||
"version": "1.0.0",
|
||||
"description": "Проект для примеры реализации приложения Task Tracker на основе макетов https://www.figma.com/design/oHNPqHqhNtil2xuWBjZfYW/Task-tracker",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"lint:fix": "eslint . --ext ts,tsx --fix",
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,css,md}\"",
|
||||
"test": "jest"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@reatom/core": "3.10.1",
|
||||
"@reatom/npm-react": "3.10.6",
|
||||
"axios": "1.11.0",
|
||||
"date-fns": "4.1.0",
|
||||
"formik": "2.4.6",
|
||||
"keycloak-js": "26.2.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1",
|
||||
"react-jss": "10.10.0",
|
||||
"react-router-dom": "7.7.1",
|
||||
"uuid": "11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "6.6.4",
|
||||
"@testing-library/react": "16.3.0",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/react": "19.1.9",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@types/uuid": "10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "8.38.0",
|
||||
"@typescript-eslint/parser": "8.38.0",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"eslint": "9.32.0",
|
||||
"eslint-config-prettier": "10.1.8",
|
||||
"eslint-plugin-jsx-a11y": "6.10.2",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"eslint-plugin-react-refresh": "0.4.20",
|
||||
"identity-obj-proxy": "3.0.0",
|
||||
"jest": "30.0.5",
|
||||
"jest-environment-jsdom": "30.0.5",
|
||||
"prettier": "3.6.2",
|
||||
"ts-jest": "29.4.0",
|
||||
"ts-node": "10.9.2",
|
||||
"typescript": "5.8.3",
|
||||
"vite": "7.0.6"
|
||||
}
|
||||
}
|
||||
53
src/app/components/App/App.tsx
Normal file
53
src/app/components/App/App.tsx
Normal file
@ -0,0 +1,53 @@
|
||||
import { BrowserRouter, Route, Routes } from 'react-router';
|
||||
import { TasksPage } from '../pages/TasksPage';
|
||||
import { BottomMenu } from '../BottomMenu/BottomMenu';
|
||||
import { CalendarPage } from '../pages/CalendarPage';
|
||||
import { BoardPage } from '../pages/BoardPage';
|
||||
import { reatomContext as ReatomContext } from '@reatom/npm-react';
|
||||
import { ctx } from '../../../core/services/ReatomContext';
|
||||
import { AuthProvider } from '../../../core/services/AuthService';
|
||||
import { AuthRoute } from '../../../core/components/AuthRoute';
|
||||
|
||||
export const App = () => {
|
||||
return (
|
||||
<ReatomContext.Provider value={ctx}>
|
||||
<AuthProvider>
|
||||
<BrowserRouter>
|
||||
<div>
|
||||
<Routes>
|
||||
<Route path="/" element={<TasksPage />} />
|
||||
<Route
|
||||
path="/calendar"
|
||||
element={
|
||||
<AuthRoute>
|
||||
<CalendarPage />
|
||||
</AuthRoute>
|
||||
}
|
||||
/>
|
||||
<Route path="/board" element={<BoardPage />} />
|
||||
</Routes>
|
||||
<BottomMenu
|
||||
items={[
|
||||
{
|
||||
title: 'Tasks',
|
||||
image: '',
|
||||
route: '/',
|
||||
},
|
||||
{
|
||||
title: 'Calendar',
|
||||
image: '',
|
||||
route: '/calendar',
|
||||
},
|
||||
{
|
||||
title: 'Board',
|
||||
image: '',
|
||||
route: '/board',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
</ReatomContext.Provider>
|
||||
);
|
||||
};
|
||||
20
src/app/components/BottomMenu/BottomMenu.tsx
Normal file
20
src/app/components/BottomMenu/BottomMenu.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import { FC } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type BottomMenuProps = {
|
||||
items: MenuItem[];
|
||||
};
|
||||
|
||||
export const BottomMenu: FC<BottomMenuProps> = ({ items }) => {
|
||||
return (
|
||||
<div>
|
||||
{items.map(({ title, image, route }) => {
|
||||
return (
|
||||
<Link key={route} to={route}>
|
||||
{title}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
5
src/app/components/BottomMenu/types.ts
Normal file
5
src/app/components/BottomMenu/types.ts
Normal file
@ -0,0 +1,5 @@
|
||||
type MenuItem = {
|
||||
image: string;
|
||||
title: string;
|
||||
route: string;
|
||||
};
|
||||
3
src/app/components/pages/BoardPage.tsx
Normal file
3
src/app/components/pages/BoardPage.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const BoardPage = () => {
|
||||
return <div>Board page</div>;
|
||||
};
|
||||
3
src/app/components/pages/CalendarPage.tsx
Normal file
3
src/app/components/pages/CalendarPage.tsx
Normal file
@ -0,0 +1,3 @@
|
||||
export const CalendarPage = () => {
|
||||
return <div>Calendar page</div>;
|
||||
};
|
||||
36
src/app/components/pages/TasksPage.tsx
Normal file
36
src/app/components/pages/TasksPage.tsx
Normal file
@ -0,0 +1,36 @@
|
||||
import { useAtom } from '@reatom/npm-react';
|
||||
import { removeTask, tasksAtom } from '../../services/TasksService';
|
||||
import { authAtom, login } from '../../../core/services/AuthService';
|
||||
|
||||
export const TasksPage = () => {
|
||||
const [tasks] = useAtom(tasksAtom);
|
||||
const [auth] = useAtom(authAtom);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
{auth.isAuth ? (
|
||||
'Привет пользователь'
|
||||
) : (
|
||||
<button type="button" onClick={login}>
|
||||
Войти
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{tasks.map(({ id, title, description }) => {
|
||||
const handleRemoveTask = () => {
|
||||
removeTask(id);
|
||||
};
|
||||
return (
|
||||
<div key={id}>
|
||||
<div>Title: {title}</div>
|
||||
<div>Description: {description}</div>
|
||||
<button type="button" onClick={handleRemoveTask}>
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
29
src/app/services/TasksService.ts
Normal file
29
src/app/services/TasksService.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import { action, atom } from '@reatom/core';
|
||||
import { v4 } from 'uuid';
|
||||
import { ctx } from '../../core/services/ReatomContext';
|
||||
|
||||
type Uuid = string;
|
||||
|
||||
type Task = {
|
||||
id: Uuid;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
export const tasksAtom = atom<Task[]>(
|
||||
[
|
||||
{ id: v4(), title: 'Clean bathroom', description: 'Clean all corners' },
|
||||
{ id: v4(), title: 'Buy a car', description: 'Go to shop and buy a car' },
|
||||
],
|
||||
'tasks',
|
||||
);
|
||||
|
||||
const removeTaskAction = action((ctx, removeTaskId: Uuid) => {
|
||||
const taskList = ctx.get(tasksAtom);
|
||||
|
||||
const filteredTaskList = taskList.filter((t) => t.id !== removeTaskId);
|
||||
|
||||
tasksAtom(ctx, filteredTaskList);
|
||||
});
|
||||
|
||||
export const removeTask = (removeTaskId: Uuid) => removeTaskAction(ctx, removeTaskId);
|
||||
13
src/core/components/AuthRoute.tsx
Normal file
13
src/core/components/AuthRoute.tsx
Normal file
@ -0,0 +1,13 @@
|
||||
import { FC, PropsWithChildren, ReactNode, useEffect } from 'react';
|
||||
import { authAtom } from '../services/AuthService';
|
||||
import {useAtom} from '@reatom/npm-react';
|
||||
|
||||
export const AuthRoute: FC<PropsWithChildren> = ({ children }) => {
|
||||
const [auth] = useAtom(authAtom);
|
||||
|
||||
if (!auth.isAuth) {
|
||||
return <div>you need auth</div>;
|
||||
}
|
||||
|
||||
return children;
|
||||
};
|
||||
67
src/core/services/AuthService.tsx
Normal file
67
src/core/services/AuthService.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { action, atom } from '@reatom/core';
|
||||
import Keycloak from 'keycloak-js';
|
||||
import { createContext, FC, PropsWithChildren, useContext, useEffect, useRef } from 'react';
|
||||
import { ctx } from './ReatomContext';
|
||||
|
||||
const authInstance = new Keycloak({
|
||||
url: 'https://auth.vigdorov.ru',
|
||||
realm: 'dev-apps',
|
||||
clientId: 'test-localapp',
|
||||
});
|
||||
|
||||
const AuthContext = createContext<typeof authInstance | undefined>(undefined);
|
||||
|
||||
type AuthData =
|
||||
| {
|
||||
isAuth: false;
|
||||
}
|
||||
| {
|
||||
isAuth: true;
|
||||
userName: string;
|
||||
};
|
||||
|
||||
export const authAtom = atom<AuthData>(
|
||||
{
|
||||
isAuth: false,
|
||||
},
|
||||
'auth',
|
||||
);
|
||||
|
||||
const changeAuthAction = action((ctx, authData: AuthData) => {
|
||||
authAtom(ctx, authData);
|
||||
});
|
||||
|
||||
export const login = () => authInstance.login();
|
||||
|
||||
export const AuthProvider: FC<PropsWithChildren> = ({ children }) => {
|
||||
const isInit = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isInit.current) {
|
||||
isInit.current = true;
|
||||
|
||||
authInstance
|
||||
.init({ onLoad: 'check-sso' })
|
||||
.then((authenticated) => {
|
||||
const userName: string =
|
||||
authInstance.tokenParsed?.given_name || authInstance.tokenParsed?.preferred_username;
|
||||
|
||||
const authData = authenticated
|
||||
? {
|
||||
isAuth: authenticated,
|
||||
userName,
|
||||
}
|
||||
: {
|
||||
isAuth: authenticated,
|
||||
};
|
||||
changeAuthAction(ctx, authData);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Ошибка инициализации Keycloak', error);
|
||||
changeAuthAction(ctx, { isAuth: false });
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <AuthContext.Provider value={authInstance}>{children}</AuthContext.Provider>;
|
||||
};
|
||||
20
src/core/services/InitApp.tsx
Normal file
20
src/core/services/InitApp.tsx
Normal file
@ -0,0 +1,20 @@
|
||||
import {useAtom} from '@reatom/npm-react';
|
||||
import {createContext, FC, PropsWithChildren, useEffect, useRef} from 'react';
|
||||
import {authAtom} from './AuthService';
|
||||
|
||||
type InitData = {
|
||||
isInit: boolean;
|
||||
};
|
||||
|
||||
const InitContext = createContext<InitData>({isInit: false});
|
||||
|
||||
export const InitProvider: FC<PropsWithChildren> = ({children}) => {
|
||||
const isInit = useRef(false);
|
||||
|
||||
const [authData] = useAtom(authAtom);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
}, [authData])
|
||||
|
||||
}
|
||||
3
src/core/services/ReatomContext.ts
Normal file
3
src/core/services/ReatomContext.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import {createCtx} from '@reatom/core';
|
||||
|
||||
export const ctx = createCtx();
|
||||
138
src/core/services/StorageService.ts
Normal file
138
src/core/services/StorageService.ts
Normal file
@ -0,0 +1,138 @@
|
||||
import axios from 'axios';
|
||||
|
||||
const BASE_URL = 'https://simple-storage.vigdorov.ru';
|
||||
|
||||
const STORAGE_NAME = 'task-tracker-app-for-students' as const;
|
||||
|
||||
type AuthRequest = {
|
||||
login: string;
|
||||
};
|
||||
|
||||
type AuthResponse = string;
|
||||
|
||||
type Uuid = string;
|
||||
|
||||
type Task = {
|
||||
id: Uuid;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type StorageForStudentsListItem = {
|
||||
user: string;
|
||||
storageName: typeof STORAGE_NAME;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type StorageForStudents = {
|
||||
data: Task[];
|
||||
user: string;
|
||||
storageName: typeof STORAGE_NAME;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type StorageCreateRequest = {
|
||||
data: any;
|
||||
storageName: string;
|
||||
};
|
||||
|
||||
type AnyStorageListItem = {
|
||||
user: string;
|
||||
storageName: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
type UpdateStorageRequest = {
|
||||
data: Task[];
|
||||
};
|
||||
|
||||
type StorageListItem = StorageForStudentsListItem | AnyStorageListItem;
|
||||
|
||||
type StorageListResponse = StorageListItem[];
|
||||
|
||||
export class StorageService {
|
||||
userName: string;
|
||||
token: string;
|
||||
id: string | undefined;
|
||||
|
||||
constructor(userName: string) {
|
||||
this.userName = userName;
|
||||
this.token = '';
|
||||
|
||||
axios
|
||||
.post<void, AuthResponse, AuthRequest>(`${BASE_URL}/auth`, {
|
||||
login: userName,
|
||||
})
|
||||
.then((token) => {
|
||||
this.token = token;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
this.token = '';
|
||||
});
|
||||
}
|
||||
|
||||
getStorage(): Promise<Task[]> {
|
||||
return axios
|
||||
.get<void, StorageListResponse>(`${BASE_URL}/storages`, {
|
||||
headers: {
|
||||
Authorization: this.token,
|
||||
},
|
||||
})
|
||||
.then((storageListResponse) => {
|
||||
const findStorage = storageListResponse.find(
|
||||
(storage): storage is StorageForStudentsListItem => storage.storageName === STORAGE_NAME,
|
||||
);
|
||||
|
||||
if (findStorage) {
|
||||
return findStorage.id;
|
||||
}
|
||||
|
||||
return axios
|
||||
.post<void, StorageForStudentsListItem, StorageCreateRequest>(
|
||||
`${BASE_URL}/storages`,
|
||||
{
|
||||
data: [],
|
||||
storageName: STORAGE_NAME,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.token,
|
||||
},
|
||||
},
|
||||
)
|
||||
.then((storageForStudentsListItem) => {
|
||||
return storageForStudentsListItem.id;
|
||||
});
|
||||
})
|
||||
.then((id) => {
|
||||
this.id = id;
|
||||
|
||||
return axios
|
||||
.get<void, StorageForStudents>(`${BASE_URL}/storages/${id}`, {
|
||||
headers: {
|
||||
Authorization: this.token,
|
||||
},
|
||||
})
|
||||
.then((storageForStudents) => storageForStudents.data);
|
||||
});
|
||||
}
|
||||
|
||||
setStorage(taskList: Task[]): Promise<void> {
|
||||
if (!this.id) {
|
||||
return Promise.reject('Id отсутствует');
|
||||
}
|
||||
|
||||
return axios.put<void, void, UpdateStorageRequest>(
|
||||
`${BASE_URL}/storages/${this.id}`,
|
||||
{
|
||||
data: taskList,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: this.token,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
12
src/index.html
Normal file
12
src/index.html
Normal file
@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Document</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="./init.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
9
src/init.tsx
Normal file
9
src/init.tsx
Normal file
@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import {App} from './app/components/App/App';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
27
tsconfig.json
Normal file
27
tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["DOM", "DOM.Iterable", "ESNext"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path Aliases */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts", "jest.config.ts"]
|
||||
}
|
||||
17
vite.config.ts
Normal file
17
vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
root: 'src',
|
||||
build: {
|
||||
outDir: '../build',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
// Порт для сервера разработки
|
||||
port: 3050,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user