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>
    );
};

30 Redux-toolkit. Entity. Тесты на всех уровнях МЕТКА

Далее нужно будет обустроить в проекте редакс.

Первым делом нужно будет проинстациировать начальную точку для нашей апишки в виде 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-бэк

|500

Настройка 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",

32 Кастомный Input. Окно авторизации. Lazy modal МЕТКА

Опишем типы для инпута

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 МЕТКА