29 Модальное окно. Portal
Далее нужно реализовать модальное окно в приложении.
Первым делом нужно создать для него портал, который будет рисовать модальное окно из другой части приложения (конкретно нужно, чтобы портал рисовался вне контента - в body
)
Пропсы:
src / shared / ui / Portal / ui / Portal.props.ts
import { ReactNode } from 'react';
export interface IPortalProps {
/** компонент, который будет отрисовываться */
children: ReactNode;
/** конечная точка, в которой он должен отрисоваться */
element?: Element | DocumentFragment;
}
Сам портал, который работает за счёт функции createPortal
src / shared / ui / Portal / ui / Portal.tsx
import { createPortal } from 'react-dom';
import { IPortalProps } from './Portal.props';
/** портал для рендера компонентов в отдельных частях приложения */
export const Portal = ({ children, element = document.body }: IPortalProps) =>
createPortal(children, element);
Далее уже реализуем хук для работы модалки. Хук используется для того, чтобы сделать логику раскрытия переиспользуемой.
src / shared / lib / hooks / useModal.tsx
import React, {
MouseEventHandler,
MutableRefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
interface IModalProps {
/** начальное состояние модального окна */
isOpened?: boolean;
}
interface IModalResult {
/** реф на модальное окно */
modalRef: MutableRefObject<HTMLDivElement | null>;
/** функция открытия модального окна */
handleOpenModal: () => void;
/** состояние модального окна */
modalExpanded: boolean;
/** функция, которая останавливает всплытие события */
stopPropagation: MouseEventHandler<HTMLDivElement>;
}
/** хук логики модального окна */
export const useModal = ({ isOpened = false }: IModalProps = {}): IModalResult => {
/** состояние отображения модального окна */
const [modalExpanded, setModalExpanded] = useState<boolean>(isOpened);
/** реф, от которого и будет работать закрытие модального окна */
const modalRef: MutableRefObject<HTMLDivElement | null> = useRef<HTMLDivElement | null>(null);
/** функция для изменения состояния окна */
const handleOpenModal = useCallback(() => setModalExpanded((prevState) => !prevState), []);
/** останавливает всплытие события */
const stopPropagation: MouseEventHandler<HTMLDivElement> = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation(),
[],
);
/** функция закрытия модалки на Esc */
const onKeyDown = useCallback((e: KeyboardEvent) => {
if (e.key === 'Escape') {
setModalExpanded(false);
}
}, []);
/** отлавливает событие закрытия окна и закрывает модалку */
const handleModalClick = useCallback((event: MouseEvent) => {
if (modalRef.current && !modalRef.current?.contains(event.target as Node)) {
setModalExpanded(false);
}
}, []);
useEffect(() => {
document.addEventListener('click', handleModalClick);
if (modalExpanded) {
document.addEventListener('keydown', onKeyDown);
}
return () => {
document.removeEventListener('click', handleModalClick);
document.removeEventListener('keydown', onKeyDown);
};
}, [handleModalClick, modalExpanded, onKeyDown]);
return {
modalRef,
handleOpenModal,
modalExpanded,
stopPropagation,
};
};
Пропсы модалки:
src / shared / ui / Modal / ui / Modal.props.ts
import { ReactNode } from 'react';
export interface IModalProps {
/** триггер открытия модалки */
label: ReactNode;
/** контент модалки */
content: ReactNode;
/** начальное состояние модалки */
isOpened?: boolean;
}
Само модальное окно уже работает за счёт хука модалки. Тут остаётся только проанимировать открытие и закрытие модалки через сторонние библиотеки для анимации компонентов
src / shared / ui / Modal / ui / Modal.tsx
import React from 'react';
import { useModal } from '@/shared/lib';
import { Card } from '../../Card';
import { Portal } from '../../Portal';
import styles from './Modal.module.scss';
import { IModalProps } from './Modal.props';
export const Modal = ({ content, label, isOpened = false }: IModalProps) => {
const { modalRef, modalExpanded, handleOpenModal, stopPropagation } = useModal({ isOpened });
return (
<div ref={modalRef} className={styles.modal}>
<div onClick={handleOpenModal}>
<span className={styles.modal__label}>{label}</span>
{modalExpanded && (
<Portal>
<div onClick={handleOpenModal} className={styles.modal__overlay}>
<Card onClick={stopPropagation} className={styles.modal__content}>
{content}
</Card>
</div>
</Portal>
)}
</div>
</div>
);
};
Далее нужно будет обустроить в проекте редакс.
Первым делом нужно будет проинстациировать начальную точку для нашей апишки в виде RTK Query
src / shared / api / common.api.ts
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { USER_LOCAL_STORAGE_KEY } from '@/shared/const';
export const rtkApi = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: __API__,
prepareHeaders: (headers: Headers /* api */): Headers => {
const token = localStorage.getItem(USER_LOCAL_STORAGE_KEY);
if (token) {
headers.set('Authorization', token);
}
return headers;
},
}),
endpoints: (builder) => ({}),
});
Далее нам нужно прописать типы тех данных, которые мы будем получать с сервера.
Конкретно нужно создать сущность User
в entity
, в которой нужно будет полностью описать модель взаимодействия с пользователем.
src / entities / User / model / types / user.interface.ts
import { IFeatures } from '@/shared/lib';
import { IJsonSettings } from './userSettings.interface';
/** список пользователей */
export interface IUserList {
users: IUser[];
}
/** интерфейс пользователя */
export interface IUser {
id: string;
username: string;
password: string;
roles: string[];
features: IFeatures;
avatar: string;
jsonSettings?: IJsonSettings;
}
Заранее можно будет прописать типы ролей пользователей
src / entities / User / model / const / userRole.const.ts
export enum EUserRole {
ADMIN = 'admin',
MANAGER = 'manager',
USER = 'user',
}
Так же для типизиации стора нужно будет написать схему пользователя. Схема будет из себя пока представлять просто тип пользователя с необязательными полями. Поля необязательны, так как пользователь может прийти незарегистрированный.
src / entities / User / model / types / user.schema.ts
import { IUser } from './user.interface';
export interface UserSchema {
user?: IUser;
}
Далее нам нужно будет реализовать схему, которая будет описывать полностью все наши данные в стейте редакса. Это нужно, чтобы в селекторах нормально получать существующие данные.
src / shared / lib / types / state.schema.ts
import { UserSchema } from '@/entities/User';
import { rtkApi } from '@/shared/api';
export interface StateSchema {
user: UserSchema;
[rtkApi.reducerPath]: ReturnType<typeof rtkApi.reducer>;
}
export interface ThunkExtraArg {
api: object;
}
Далее эту схему нужно будет передать и в инишл стейт слайса
src / entities / User / model / slice / user.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { IUser, UserSchema } from '../types';
const initialState: UserSchema = {};
const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setUser: (state: UserSchema, action: PayloadAction<IUser>) => {
state.user = action.payload;
},
},
});
export const { actions: userActions } = userSlice;
export const { reducer: userReducers } = userSlice;
Далее таким образом уже будут выглядеть тесты слайса пользователя:
src / entities / User / model / slice / user.slice.test.ts
import { IUser, userActions, userReducers } from '@/entities/User';
import { UserSchema } from '../types';
describe('User Slice Suite', () => {
test('set user into state', () => {
const state: UserSchema = {
user: {
id: '12231231',
avatar: 'asdasd',
features: {
isAppRedesigned: false,
isCounterEnabled: false,
isArticleRatingEnabled: false,
},
password: 'asdasd',
username: 'Alex',
roles: ['admin'],
},
};
expect(userReducers(state, userActions.setUser(state.user as IUser))).toEqual({
user: state.user,
});
});
test('set user undefined', () => {
const state: UserSchema = {
user: undefined,
};
expect(userReducers(undefined, userActions.setUser(state.user as IUser))).toEqual({
user: undefined,
});
});
});
Далее нужно будет описать типы для получения пользователя в RTK Query
src / entities / User / model / types / user.service.interface.ts
import { IUser } from './user.interface';
export interface IGetUserRequest {
id: string | number;
}
export interface IGetUserResponse extends IUser {}
И далее нужно будет заинжектить через injectEndpoints
ендпоинты в корневой rtkApi
src / entities / User / api / user.api.ts
import { rtkApi } from '@/shared/api';
import { IGetUserRequest, IGetUserResponse } from '../model/types';
const userApi = rtkApi.injectEndpoints({
endpoints: ({ query, mutation }) => ({
getUser: query<IGetUserResponse, IGetUserRequest>({
query: ({ id }) => ({
url: '/user/' + id,
}),
}),
}),
});
export const { useGetUserQuery } = userApi;
И далее в конфиге провайдера нужно будет реализовать функцию, которая будет собирать стейт из различных асинхронных редьюсеров и начального состояния.
Стейт полностью протипизирован и реализован минимальный конфиг для devTools
src / app / providers / StoreProvider / config / store.ts
import {
Action,
AnyAction,
configureStore,
MiddlewareArray,
ReducersMapObject,
ThunkMiddleware,
} from '@reduxjs/toolkit';
import { ToolkitStore } from '@reduxjs/toolkit/dist/configureStore';
import { CurriedGetDefaultMiddleware } from '@reduxjs/toolkit/dist/getDefaultMiddleware';
// import { Reducer, CombinedState } from 'redux';
import { userReducers } from '@/entities/User';
import { rtkApi } from '@/shared/api';
import { StateSchema, ThunkExtraArg } from '@/shared/lib';
/**
* Функция сборки редакс-стейта
* */
export const createReduxStore = (
initialState?: StateSchema,
asyncReducers?: ReducersMapObject<StateSchema>,
) => {
const reducers: ReducersMapObject<StateSchema> = {
...asyncReducers,
user: userReducers,
[rtkApi.reducerPath]: rtkApi.reducer,
};
const extraArgument: ThunkExtraArg = { api: {} };
const store: ToolkitStore<
StateSchema,
Action<unknown>,
MiddlewareArray<[ThunkMiddleware<StateSchema, AnyAction, ThunkExtraArg>]>
> = configureStore({
reducer: reducers, // as Reducer<CombinedState<StateSchema>>,
preloadedState: initialState,
devTools: __IS_DEV__ ? { shouldHotReload: true } : false,
middleware: (getDefaultMiddleware: CurriedGetDefaultMiddleware<StateSchema>) =>
getDefaultMiddleware({ thunk: { extraArgument } }),
});
return store;
};
export type TRootState = ReturnType<typeof createReduxStore>['getState'];
export type TAppDispatch = ReturnType<typeof createReduxStore>['dispatch'];
После организации стора нужно реализовать хуки для диспетча и получения данных
src / shared / lib / hooks / useReduxValue.tsx
import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
import { TAppDispatch, TRootState } from '@/app/providers/StoreProvider';
/** хук диспетча */
export const useAppDispatch = () => useDispatch<TAppDispatch>();
/** хук селектора */
export const useAppSelector: TypedUseSelectorHook<TRootState> = useSelector;
/** получение значения из рекдакса */
export const useReduxValue = () => {};
И теперь мы можем реализовать провайдер для стора
src / app / providers / StoreProvider / ui / StoreProvider.tsx
import { ReducersMapObject } from '@reduxjs/toolkit';
import { FC, ReactNode } from 'react';
import { Provider } from 'react-redux';
import { StateSchema } from '@/shared/lib';
import { createReduxStore } from '../config/store';
interface IStoreProviderProps {
children: ReactNode;
initialState?: DeepPartial<StateSchema>;
asyncReducers?: DeepPartial<ReducersMapObject<StateSchema>>;
}
export const StoreProvider: FC<IStoreProviderProps> = ({
children,
initialState,
asyncReducers,
}: IStoreProviderProps) => {
const store = createReduxStore(
initialState as StateSchema,
asyncReducers as ReducersMapObject<StateSchema>,
);
return <Provider store={store}>{children}</Provider>;
};
Далее нужно поговорить про библиотеку reselect
, которая уже встроена в RTK. Конкретно функция createSelector
позволяет нам получать сразу несколько значений из стора и производить над ними определённые операции в конечном коллбэке.
Так же эта функция занимается мемоизацией полученного значения like useMemo
const selectTodosByCategory = createSelector(
[
// прокидываем массив функций-селекторов
(state: RootState) => state.todos,
(state: RootState, category: string) => category
],
// получаем из них значения и производим над ними операции
(todos, category) => {
return todos.filter(t => t.category === category)
}
)
И тут уже можно будет воспользоваться функцией createSelector
которая выполнит нужную логику в селекторе и замемоизирует значение
src / entities / User / model / selectors / getUserRole.selector.ts
import { createSelector } from '@reduxjs/toolkit';
import { EUserRole } from '@/entities/User';
import { StateSchema } from '@/shared/lib';
export const getUserRole = (state: StateSchema) => state.user.user?.roles;
export const isUserAdmin = createSelector(
getUserRole,
(roles) => !!roles?.includes(EUserRole.ADMIN),
);
export const isUserManager = createSelector(
getUserRole,
(roles) => !!roles?.includes(EUserRole.MANAGER),
);
А уже так будут выглядеть тесты селекторов:
src / entities / User / model / selectors / getUserRole / getUserRole.selector.test.ts
import { getUserRole, isUserAdmin, isUserManager } from '@/entities/User';
import { StateSchema } from '@/shared/lib';
describe('getUserRole', () => {
test('is user', () => {
const state: DeepPartial<StateSchema> = {
user: {
user: {
roles: ['user'],
},
},
};
expect(getUserRole(state as StateSchema)).toEqual(['user']);
});
test('is manager', () => {
const state: DeepPartial<StateSchema> = {
user: {
user: {
roles: ['manager'],
},
},
};
expect(isUserManager(state as StateSchema)).toEqual(true);
});
test('is admin', () => {
const state: DeepPartial<StateSchema> = {
user: {
user: {
roles: ['admin'],
},
},
};
expect(isUserAdmin(state as StateSchema)).toEqual(true);
});
});
Далее добавляем провайдер стора в приложение
src / index.tsx
import React, { StrictMode, Suspense } from 'react';
import { createRoot, Root } from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom';
import App from '@/app/App';
import { ErrorBoundary } from '@/app/providers/ErrorBoundary';
import { StoreProvider } from '@/app/providers/StoreProvider';
import { ThemeProvider } from '@/app/providers/ThemeProvider';
import { Skeleton } from '@/widgets/Skeleton';
import '@/shared/config/i18n/i18n';
const root: Root = createRoot(document.getElementById('root') as HTMLElement);
if (!root) {
throw new Error('В приложение не вмонтирован root div !!');
}
root.render(
<StoreProvider>
<BrowserRouter>
<ErrorBoundary>
<ThemeProvider>
<StrictMode>
<Suspense fallback={<Skeleton />}>
<App />
</Suspense>
</StrictMode>
</ThemeProvider>
</ErrorBoundary>
</BrowserRouter>
</StoreProvider>,
);
31.1 Json server. Имитация бэкенда
Устанавливаем зависимости для поднятия искуственного бэк-сервера
npm install --save-dev json-server nodemon ts-node @types/json-server @types/nodemon
Добавим немного моковых данных в наш сервер
server / db.json
"users": [
{
"id": "1",
"username": "admin",
"password": "123",
"roles": [
"ADMIN"
],
"features": {
"isArticleRatingEnabled": true,
"isCounterEnabled": true,
"isAppRedesigned": true
},
"avatar": "https://mobimg.b-cdn.net/v3/fetch/22/2207633df03a819cd72889249c8361a8.jpeg?w=1470&r=0.5625",
"jsonSettings": {
"isArticlesPageWasOpened": true,
"theme": "app_dark_theme"
}
},
...
Далее нам нужно будет сгенерировать пару сертификатов, чтобы у нас была возможность поднять https-бэк
Настройка json-server
, которая позволит нам имитировать настоящий сервер
server / index.ts
import fs from 'fs';
import http from 'http';
import https from 'https';
import path from 'path';
import jsonServer from 'json-server';
import { IUser } from './types/user.interface';
/**
* Сертификаты для https-сервера *
* */
const options = {
key: fs.readFileSync(path.resolve(__dirname, 'key.pem')),
cert: fs.readFileSync(path.resolve(__dirname, 'cert.pem')),
};
/**
* инстанциируем json-сервер *
* */
const server = jsonServer.create();
/**
* инстанциируем роутер по файлу с данными
* */
const router = jsonServer.router(path.resolve(__dirname, 'db.json'));
server.use(jsonServer.defaults({}));
server.use(jsonServer.bodyParser);
/**
* Для имитации "реального" апи добавлена небольшая задержка для возврата данных
* */
server.use(async (req, res, next) => {
await new Promise((res) => {
setTimeout(res, 800);
});
next();
});
/**
* эндпоинт авторизации пользователя *
* */
server.post('/login', (req, res) => {
try {
const { username, password } = req.body;
const db = JSON.parse(
fs.readFileSync(path.resolve(__dirname, 'db.json'), { encoding: 'utf-8' }),
);
const { users = [] } = db;
const userFromBd = users.find(
(user: IUser) => user.username === username && user.password === password,
);
if (userFromBd) {
return res.json(userFromBd);
}
return res.status(403).json({ message: 'User not found' });
} catch (e) {
if (e instanceof Error) {
console.log(e);
return res.status(500).json({ message: e.message });
}
}
});
/**
* гуард авторизации пользователя * просто проверяет заголовки авторизации пользователя в запросе
* */
server.use((req, res, next) => {
if (!req.headers.authorization) {
return res.status(403).json({ message: 'AUTH ERROR' });
}
next();
});
server.use(router);
/**
* старт сервера
*/
const HTTPS_PORT = 8443;
const HTTP_PORT = 8000;
const httpsServer = https.createServer(options, server);
const httpServer = http.createServer(server);
httpsServer.listen(HTTPS_PORT, () => {
console.log(`https server is running on ${HTTPS_PORT} port`);
});
httpServer.listen(HTTP_PORT, () => {
console.log(`http server is running on ${HTTP_PORT} port`);
});
Минимальный список интерфейсов, которые нужно описать для пользователя
server / types / user.interface.ts
/** список пользователей */
export interface IUserList {
users: IUser[];
}
/** интерфейс пользователя */
export interface IUser {
id: string;
username: string;
password: string;
roles: string[];
features: IFeatures;
avatar: string;
jsonSettings?: IJsonSettings;
}
/** список фич, которые активировал пользователь */
export interface IFeatures {
isArticleRatingEnabled: boolean;
isCounterEnabled: boolean;
isAppRedesigned?: boolean;
}
/** настройки пользователя */
export interface IJsonSettings {
isArticlesPageWasOpened: boolean;
theme?: string;
}
Команда поднятия сервера
package.json
"start:dev:server": "nodemon ./server/index.ts",
Опишем типы для инпута
src / shared / ui / Input / ui / Input.props.ts
export interface IInputAttributes
extends DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement> {}
export interface InputProps extends Omit<IInputAttributes, 'onChange' | 'value'> {
autofocus?: boolean;
value?: string;
onChange?: (value: string) => void;
}
Реализуем инпут, который сразу будет принимать на себя фокус. Так же, чтобы было проще вводить данные в него, сразу захэндлим инпут, чтобы тот передавал в onChange
сразу значение из инпута
src / shared / ui / Input / ui / Input.tsx
import React, { ChangeEvent, MutableRefObject, useEffect, useId, useRef, useState } from 'react';
import { cn } from '@/shared/lib';
import { InputProps } from './Input.props';
export const Input = ({ autofocus, onChange, value, type = 'text', ...props }: InputProps) => {
const id = useId();
const ref: MutableRefObject<HTMLInputElement | null> = useRef<HTMLInputElement>(null);
const [focus, setFocus] = useState<boolean>(false);
/** стили при активном инпуте */
const onFocus = () => setFocus(true);
/** стили при выходе из инпута */
const onBlur = () => setFocus(false);
const handleOnChange = (event: ChangeEvent<HTMLInputElement>) => {
onChange?.(event.target.value);
};
useEffect(() => {
if (autofocus) {
setFocus(true);
ref.current?.focus();
}
}, [autofocus]);
return (
<label htmlFor={id} className={cn()}>
<input
ref={ref}
id={id}
type={type}
onChange={handleOnChange}
value={value}
onFocus={onFocus}
onBlur={onBlur}
{...props}
/>
</label>
);
};
Далее нужно описать схему для окна авторизации
src / features / BaseAuth / model / types / schema / baseAuth.schema.ts
export interface BaseAuthSchema {
username?: string;
password?: string;
}
Создаём слайс для ввода нового значения в состояние
src / features / BaseAuth / model / slice / baseAuth.slice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { BaseAuthSchema } from '../types/schema/baseAuth.schema';
const initialState: BaseAuthSchema = {};
const baseAuthSlice = createSlice({
name: 'baseAuth',
initialState,
reducers: {
setPassword: (state: BaseAuthSchema, action: PayloadAction<string>) => {
state.password = action.payload;
},
setUsername: (state: BaseAuthSchema, action: PayloadAction<string>) => {
state.username = action.payload;
},
},
});
export const { reducer: baseAuthReducer } = baseAuthSlice;
export const { actions: baseAuthActions } = baseAuthSlice;
Далее нужно реализовать селектор логина
src / features / BaseAuth / model / selectors / getLogin / getLogin.selector.ts
import { StateSchema } from '@/shared/types';
export const getLogin = (state: StateSchema) => state.baseAuth?.username || '';
И пароля
src / features / BaseAuth / model / selectors / getPassword / getPassword.selector.ts
import { StateSchema } from '@/shared/types';
export const getPassword = (state: StateSchema) => state?.baseAuth?.password || '';
И таким образом будет реализована передача данных через инпуты
src / features / BaseAuth / ui / BaseAuth.tsx
import React, { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { DynamicModuleLoader, ReducerList, useAppDispatch, useAppSelector } from '@/shared/lib';
import { Input } from '@/shared/ui';
import { getPassword, getLogin } from '../model/selectors';
import { baseAuthReducer, baseAuthActions } from '../model/slice/baseAuth.slice';
const initialReducers: ReducerList = {
baseAuth: baseAuthReducer,
};
export const BaseAuth = () => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const login = useAppSelector(getLogin);
const password = useAppSelector(getPassword);
const loginOnChange = useCallback(
(value: string) => dispatch(baseAuthActions.setUsername(value)),
[dispatch],
);
const passwordOnChange = useCallback(
(value: string) => dispatch(baseAuthActions.setPassword(value)),
[dispatch],
);
return (
<DynamicModuleLoader removeAfterUnmount reducers={initialReducers}>
<div>{t('Auth')}</div>
<Input placeholder={'Логин'} onChange={loginOnChange} value={login} />
<Input placeholder={'Пароль'} onChange={passwordOnChange} value={password} />
</DynamicModuleLoader>
);
};
33 Husky. Pre commit хуки
Плагин husky будет за нас выполнять какие-либо простые операции до того, как мы зальём изменения в ветку.
Для начала нужно будет проинициализировать хаски:
npx husky-init
Далее нужно будет добавить наши npm-скрипты
.husky / pre-commit
#!/usr/bin/env sh
. "$( dirname -- " $0 ")/_/husky.sh"
npm run lint:fix:all
npm run test:unit
npm run storybook:build
npm run test:ui:ci
34 Авторизация. Reducers, slices, async thunk. Custom text МЕТКА