Мокирование данных

Мокирование данных

На главной странице (шапку для которой мы сделали ранее) нам необходимо отобразить полный список аудиторий. При развертывании на продашкн системе данные придут с сервера, но пока бэкенда нет — заменим данные моками с помощью MSW (Mock Service Worker) . Основное преимущество такого подхода заключается в том, что логика всех остальных компонентов при этом не меняются: сетевой вызов идет по базовому маршруту /api/rooms, а разницы кто ответил для них нет.

Для выполнения сетевых запросов мы будем использовать легковесную и удобную библиотеку axios .

Также на всякий случай проверим, что установлен пакет MUI . Он уже использовался в руководстве по созданию базовых компонентов, но на всякий случай проверим установку пакета, если его кто пропустил. MUI содержит готовые стилизованные элементы: базовые компоненты (Table, Chip, IconButton и т.д.) и набор иконок из @mui/icons-material.

При развёртывании на продакшн-окружении данные придут с сервера. Пока бэкенда нет — подменяем ответ /api/rooms через MSW (Mock Service Worker): это имитирует сетевой слой без изменения остального кода.

Установка зависимостей

Terminal — npm
npm i axios msw @mui/material @emotion/react @emotion/styled @mui/icons-material clsx
Terminal — pnpm
pnpm add axios msw @mui/material @emotion/react @emotion/styled @mui/icons-material clsx
Terminal — yarn
yarn add axios msw @mui/material @emotion/react @emotion/styled @mui/icons-material clsx

React Context API (авторизация)

Одна из проблем, которая возникает практически всегда при разработке нового приложения - как организовать авторизацию. На стороне фронтенда нам зачастую приходится заниматься pros drilling’ом (передавать свойства в качестве аргументов компонентам на уровень ниже). Современным и логичным решением в данном случае будет использовать React Context API (или другие библиотеки хранения состояния, например Zustand / Redux). Он будет хранить краткую информацию о пользователе, состояние авторизации и простые методы signIn/signOut. До того момента пока бэкенда нет, эти методы будут просто менять состояние и синхронизироваться с localStorage.

localStorage - это веб-хранилище, которое позволяет хранить объекта вида ключ-значение в браузере. Подробнее можно ознакомиться тут .

Для начала всё в одном модуле: типы, контекст, провайдер и хук useAuth():

src/context/auth.tsx
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import { createContext, useContext, useEffect, useMemo, useState } from "react";

export interface UserBrief { id?: string; name?: string; avatarUrl?: string; }
export interface AuthContextValue {
  user: UserBrief | null;
  isAuthenticated: boolean;
  loading: boolean;
  signIn(profile: UserBrief): void | Promise<void>;
  signOut(): void | Promise<void>;
  update(patch: Partial<UserBrief>): void;
}

const AuthContext = createContext<AuthContextValue | null>(null);
const STORAGE_KEY = "rb:user";

export function AuthProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<UserBrief | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    try {
      const raw = localStorage.getItem(STORAGE_KEY);
      if (raw) setUser(JSON.parse(raw));
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    if (loading) return;
    if (user) localStorage.setItem(STORAGE_KEY, JSON.stringify(user));
    else localStorage.removeItem(STORAGE_KEY);
  }, [user, loading]);

  const signIn = (profile: UserBrief) => setUser(profile);
  const signOut = () => setUser(null);
  const update = (p: Partial<UserBrief>) => setUser((u) => ({ ...(u ?? {}), ...p }));

  const value = useMemo<AuthContextValue>(
    () => ({ user, isAuthenticated: !!user, loading, signIn, signOut, update }),
    [user, loading]
  );

  return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}

export function useAuth() {
  const ctx = useContext(AuthContext);
  if (!ctx) throw new Error("useAuth must be used within <AuthProvider>");
  return ctx;
}

Сразу подключаем провайдера в корне приложения (чтобы он был доступен в других частях проекта):

src/main.tsx
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>,
)

Теперь можно обновить шапку, которую мы с вами делали в рамках прошлых лабораторных работы. Профиль пользователя уже может быть получен из контекста и инициалы или аватар будут отображаться с его помощью.:

src/components/Header/Header.tsx
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-модулях:

src/api/http.ts
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 . А полный файл спецификации, по которой автоматически генерируется документация доступен по этому маршруту .

src/api/roomsApi.ts
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 — Подготовить данные и обработчик

src/mocks/data.ts
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,
};
src/mocks/handlers.ts
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 });
  }),
];
src/mocks/browser.ts
import { setupWorker } from "msw/browser";
import { handlers } from "./handlers";

export const worker = setupWorker(...handlers);

Шаг 2 — Сгенерировать Service Worker

Не забудем инициализировать сам файл сервис-воркера, чтобы MSW запустился корректно:

Terminal
npx msw init public --save

В директории public появится mockServiceWorker.js (регистрация — через worker.start(...)). Подробности про регистрацию и dev/HTTPS нюансы см. в руководствах MSW.

Шаг 3 — Инициализировать моки в React

Моки должны инициализироваться перед монтированием React-приложения. Также добавим условие, чтобы моки применялись только к dev сборке:

src/main.tsx
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>,
  )
});
MSW регистрирует Service Worker и перехватывает запросы только в браузере; в dev-окружении допустим HTTP на localhost, но для продакшна нужен HTTPS.

Таблица аудиторий (MUI)

Теперь любая функция, использующая http.get("/rooms"), получит ответ из MSW, а код останется идентичным тому, что будет работать с реальным API. Оформим таблицу:

src/components/RoomsTable/RoomsTable.tsx
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
import { useEffect, useState } from "react";
import {
  Paper, Table, TableHead, TableRow, TableCell, TableBody,
  Chip, CircularProgress, Box, IconButton, Stack, Typography
} from "@mui/material";
import { VisibilityOutlined, EditOutlined, DeleteOutline, Groups2Outlined } from "@mui/icons-material";
import { fetchRooms, type RoomDto } from "@/api/roomsApi";

const STATUS_LABEL: Record<RoomDto["status"], string> = {
  available: "Доступна",
  booked: "Забронирована",
  maintenance: "На обслуживании",
};
const STATUS_COLOR: Record<RoomDto["status"], "success" | "warning" | "default"> = {
  available: "success",
  booked: "warning",
  maintenance: "default",
};
const EQUIP_LABEL: Record<string, string> = {
  projector: "Проектор", microphone: "Микрофон", wifi: "Wi-Fi",
  computers: "Компьютеры", board: "Доска",
};

export function RoomsTable() {
  const [loading, setLoading] = useState(true);
  const [error, setError]     = useState<string | null>(null);
  const [items, setItems]     = useState<RoomDto[]>([]);

  useEffect(() => {
    let mounted = true;
    (async () => {
      try {
        setLoading(true);
        const data = await fetchRooms(1);
        if (mounted) setItems(data.items);
      } catch (e) {
        if (mounted) setError((e as Error).message || "Ошибка загрузки");
      } finally {
        if (mounted) setLoading(false);
      }
    })();
    return () => { mounted = false; };
  }, []);

  if (loading) return <Box sx={{ p: 3, display: "grid", placeItems: "center" }}><CircularProgress /></Box>;
  if (error)   return <Box sx={{ p: 3 }}><Typography color="error">Не удалось загрузить данные: {error}</Typography></Box>;

  return (
    <Paper elevation={0} sx={{ borderRadius: 2, overflow: "hidden", border: "1px solid #eef0f3" }}>
      <Table size="small">
        <TableHead>
          <TableRow>
            <TableCell width={100}>Номер</TableCell>
            <TableCell>Название</TableCell>
            <TableCell width={160} align="right">Вместимость</TableCell>
            <TableCell>Оборудование</TableCell>
            <TableCell width={170}>Статус</TableCell>
            <TableCell width={120} align="center">Действия</TableCell>
          </TableRow>
        </TableHead>
        <TableBody>
          {items.map((r) => (
            <TableRow key={r.id} hover>
              <TableCell sx={{ color: "text.secondary" }}>{r.code}</TableCell>
              <TableCell>
                <Stack spacing={0.5}>
                  <Typography fontWeight={600}>{r.name}</Typography>
                </Stack>
              </TableCell>
              <TableCell align="right">
                <Stack direction="row" spacing={1} justifyContent="flex-end" alignItems="center">
                  <Groups2Outlined fontSize="small" />
                  <span>{r.capacity}</span>
                </Stack>
              </TableCell>
              <TableCell>
                <Stack direction="row" spacing={1} useFlexGap flexWrap="wrap">
                  {r.equipment.map((k) => <Chip key={k} label={EQUIP_LABEL[k] ?? k} size="small" variant="outlined" />)}
                </Stack>
              </TableCell>
              <TableCell>
                <Chip
                  label={STATUS_LABEL[r.status]}
                  size="small"
                  color={STATUS_COLOR[r.status]}
                  variant={r.status === "maintenance" ? "outlined" : "filled"}
                />
              </TableCell>
              <TableCell align="center">
                <IconButton size="small" title="Просмотр"><VisibilityOutlined fontSize="small" /></IconButton>
                <IconButton size="small" title="Редактировать"><EditOutlined fontSize="small" /></IconButton>
                <IconButton size="small" color="error" title="Удалить"><DeleteOutline fontSize="small" /></IconButton>
              </TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
    </Paper>
  );
}

Собираем страницу:

src/App.tsx
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;