Мокирование данных
На главной странице (шапку для которой мы сделали ранее) нам необходимо отобразить полный список аудиторий. При развертывании на продашкн системе данные придут с сервера, но пока бэкенда нет — заменим данные моками с помощью MSW (Mock Service Worker). Основное преимущество такого подхода заключается в том, что логика всех остальных компонентов при этом не меняются: сетевой вызов идет по базовому маршруту /api/rooms, а разницы кто ответил для них нет.
Для выполнения сетевых запросов мы будем использовать легковесную и удобную библиотеку axios.
Также на всякий случай проверим, что установлен пакет MUI. Он уже использовался в руководстве по созданию базовых компонентов, но на всякий случай проверим установку пакета, если его кто пропустил. MUI содержит готовые стилизованные элементы: базовые компоненты (Table, Chip, IconButton и т.д.) и набор иконок из @mui/icons-material.
/api/rooms через MSW (Mock Service Worker): это имитирует сетевой слой без изменения остального кода.Установка зависимостей
npm i axios msw @mui/material @emotion/react @emotion/styled @mui/icons-material clsxpnpm add axios msw @mui/material @emotion/react @emotion/styled @mui/icons-material clsxyarn add axios msw @mui/material @emotion/react @emotion/styled @mui/icons-material clsxReact Context API (авторизация)
Одна из проблем, которая возникает практически всегда при разработке нового приложения - как организовать авторизацию. На стороне фронтенда нам зачастую приходится заниматься pros drilling’ом (передавать свойства в качестве аргументов компонентам на уровень ниже). Современным и логичным решением в данном случае будет использовать React Context API (или другие библиотеки хранения состояния, например Zustand / Redux). Он будет хранить краткую информацию о пользователе, состояние авторизации и простые методы signIn/signOut. До того момента пока бэкенда нет, эти методы будут просто менять состояние и синхронизироваться с localStorage.
localStorage - это веб-хранилище, которое позволяет хранить объекта вида ключ-значение в браузере. Подробнее можно ознакомиться тут.
Для начала всё в одном модуле: типы, контекст, провайдер и хук useAuth():
|
|
Сразу подключаем провайдера в корне приложения (чтобы он был доступен в других частях проекта):
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from '@/context/auth'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)Теперь можно обновить шапку, которую мы с вами делали в рамках прошлых лабораторных работы. Профиль пользователя уже может быть получен из контекста и инициалы или аватар будут отображаться с его помощью.:
import clsx from "clsx";
import s from "./Header.module.css";
import type { NavItem } from "./header.types";
import { DEFAULT_NAV } from "./header.config";
import { useAuth } from "@/context/auth";
import { DomainRounded, NotificationsNoneOutlined } from "@mui/icons-material";
export function Header({
navItems = DEFAULT_NAV,
activeNavId,
onNavigate,
onBellClick,
}: {
navItems?: NavItem[];
activeNavId: string;
onNavigate: (id: string) => void;
onBellClick?: () => void;
}) {
const { user } = useAuth();
const initials = (name?: string) => {
if (!name) return "?";
const [a = "", b = ""] = name.trim().split(/\s+/);
return (a[0] ?? "").concat(b[0] ?? "").toUpperCase();
};
return (
<header className={s.header}>
<div className={s.row}>
<div className={s.brand}>
<div className={s.logoBox} aria-hidden>
<DomainRounded className={s.logoIc} />
</div>
<div className={s.app}>Room Booking</div>
</div>
<nav className={s.nav} aria-label="Основная навигация">
{navItems.map((item) => {
const active = item.id === activeNavId;
return (
<button
key={item.id}
type="button"
className={clsx(s.tab, active && s.tabActive)}
onClick={() => onNavigate(item.id)}
aria-current={active ? "page" : undefined}
title={item.label}
>
{item.icon && <item.icon className={s.tabIc} />}
<span>{item.label}</span>
</button>
);
})}
</nav>
<div className={s.spacer} />
<div className={s.right}>
<button type="button" className={s.iconBtn} onClick={onBellClick} aria-label="Уведомления" title="Уведомления">
<NotificationsNoneOutlined />
</button>
<div className={s.avatar} title={user?.name || "Гость"}>
{user?.avatarUrl ? (
<img src={user.avatarUrl} alt="" width={32} height={32} style={{ borderRadius: "999px" }} />
) : (
<span>{initials(user?.name)}</span>
)}
</div>
</div>
</div>
</header>
);
}Сетевой слой (axios)
Подготовим основу сетевого слоя, через который будем централизованно будем создавать и обрабатывать запросы. Для этого создадим единый инстанс axios c базовым URL /api, который будем переиспользовать в других API-модулях:
import axios from "axios";
export const http = axios.create({
baseURL: "/api",
timeout: 10_000,
headers: { "Content-Type": "application/json" },
});
http.interceptors.response.use(
(r) => r,
(err) => { console.error("HTTP error:", err); throw err; }
);Контракт ответа и функцию запроса выносим отдельно. Формат держим таким, какой ожидает таблица и какой будет у реального API: {"items":[...],"page":1,"total":156}:
Что такое «контракт ответа сервера» и зачем он нужен?
Контракт ответа сервера — это часть API-контракта, которая формально описывает, что именно и в каком виде сервер возвращает: коды статуса (200/400/500 и т. д.), заголовки, структуру и типы полей JSON/XML, возможные ошибки и их форматы. Такой контракт фиксируется в машиночитаемой спецификации (чаще всего OpenAPI + JSON Schema). Он служит «договором» между бэкендом и клиентами (веб/мобайл/другие сервисы) о правилах обмена данными.
Например, в OpenAPI Specification (последняя на текущий момент версия 3.2.0) контракт ответа задают в разделе responses:
paths:
/users/{id}:
get:
responses:
"200":
description: OK
content:
application/json:
schema: # контракт ответа
type: object
required: [id, name]
properties:
id: { type: string, format: uuid }
name: { type: string }
email:{ type: string, format: email }
"404":
description: Not Found
content:
application/json:
schema:
type: object
required: [error, code]
properties:
error: { type: string }
code: { type: string, enum: [NOT_FOUND] }Ссылка на вариант API маршрутов, который я вам предлагаю (обсуждаемо): labs.1n0name.ru. А полный файл спецификации, по которой автоматически генерируется документация доступен по этому маршруту.
import { http } from "./http";
export type RoomStatus = "available" | "booked" | "maintenance";
export interface RoomDto {
id: string;
code: string;
name: string;
capacity: number;
equipment: string[];
status: RoomStatus;
}
export interface RoomsResponseDto {
items: RoomDto[];
page: number;
total: number;
}
export async function fetchRooms(page = 1): Promise<RoomsResponseDto> {
const { data } = await http.get<RoomsResponseDto>("/rooms", { params: { page } });
return data;
}Моки через MSW
Но у нас же с вами еще нет сервера - возникает вопрос что делать в этой ситуации? Для имитации некоторых запросов на сервер воспользуемся библиотекой MSW, перехватывая запросы на уровне Service Worker. В этом случае поведение компонентов максимально будет приближенно к реальному: весь процесс совершения запроса будет виден в DevTools. Подготавливаем данные и обработчик маршрута /api/rooms:
Шаг 1 — Подготовить данные и обработчик
import type { RoomsResponseDto } from "@/api/roomsApi";
export const roomsPayload: RoomsResponseDto = {
items: [
{ id: "201", code: "201", name: "Конференц-зал", capacity: 50, equipment: ["projector","microphone","wifi"], status: "available" },
{ id: "101", code: "101", name: "Лекционная аудитория", capacity: 120, equipment: ["projector","wifi"], status: "available" },
{ id: "102", code: "102", name: "Компьютерный класс", capacity: 30, equipment: ["computers","projector","board","wifi"], status: "booked" },
{ id: "202", code: "202", name: "Семинарская", capacity: 25, equipment: ["board","wifi"], status: "maintenance" },
],
page: 1,
total: 156,
};import { http as msw, HttpResponse } from "msw";
import { roomsPayload } from "./data";
export const handlers = [
msw.get("/api/rooms", ({ request }) => {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page") ?? "1");
return HttpResponse.json({ ...roomsPayload, page });
}),
];import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";
export const worker = setupWorker(...handlers);Шаг 2 — Сгенерировать Service Worker
Не забудем инициализировать сам файл сервис-воркера, чтобы MSW запустился корректно:
npx msw init public --saveВ директории public появится mockServiceWorker.js (регистрация — через worker.start(...)). Подробности про регистрацию и dev/HTTPS нюансы см. в руководствах MSW.
Шаг 3 — Инициализировать моки в React
Моки должны инициализироваться перед монтированием React-приложения. Также добавим условие, чтобы моки применялись только к dev сборке:
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { AuthProvider } from '@/context/auth'
async function enableMocking() {
if (import.meta.env.DEV) {
const { worker } = await import('./mocks/browser')
await worker.start({
onUnhandledRequest: "bypass",
serviceWorker: { url: "/mockServiceWorker.js" },
});
}
}
enableMocking().then(() => {
createRoot(document.getElementById('root')!).render(
<StrictMode>
<AuthProvider>
<App />
</AuthProvider>
</StrictMode>,
)
});localhost, но для продакшна нужен HTTPS.Таблица аудиторий (MUI)
Теперь любая функция, использующая http.get("/rooms"), получит ответ из MSW, а код останется идентичным тому, что будет работать с реальным API. Оформим таблицу:
|
|
Собираем страницу:
import { useState } from 'react'
import { Container, Box } from "@mui/material";
import { Header } from './components/Header';
import { RoomsTable } from '@/components/RoomsTable/RoomsTable';
import './App.css'
function App() {
const [active, setActive] = useState("catalog");
return (
<>
<Header
activeNavId={active}
onNavigate={setActive}
onBellClick={() => console.log("bell")}
/>
<Container maxWidth="lg">
<Box sx={{ my: 2 }}>
<RoomsTable />
</Box>
</Container>
</>
);
}
export default App;