Введение

В этой статье я расскажу, как организовал свой проект на Next.js. На примере реального приложения рассмотрены структура проекта, организация маршрутизации, работа с данными через DAL, аутентификация, валидация, тестирование и другие подходы, которые я использовал в процессе разработки. Статья не претендует на универсальное руководство, а показывает один из возможных вариантов организации приложения.

Краткий обзор

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

  • Просмотр данных — для работы с задачами доступны разные режимы отображения (список и карточки), поиск по заголовку, сортировка по различным полям, пагинация и различные фильтры.

  • Детализация — каждый элемент списка включает интерактивные заголовки задачи, а также связанного проекта и исполнителя. Нажатие на любой из них открывает боковую панель с подробной информацией, позволяя просматривать детали без перехода на отдельную страницу. При необходимости из боковой панели можно перейти на страницу детализации соответствующей сущности.

  • Управление с клавиатуры — в системе поддерживается управление интерфейсом с клавиатуры. На примере базовых действий показана работа с задачами только с помощью клавиатуры: создание, редактирование, удаление и изменение статуса.

  • Мобильная версия — приложение имеет адаптивный интерфейс и поддерживает устройства с различными разрешениями экрана. Мобильная версия приложения имеет некоторые отличия по функциональности от десктопной, что сделано для улучшения пользовательского опыта.

Структура проекта

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

├── app/
│   ├── [locale]/
│   │   ├── (auth)/                   # Роуты аутентификации
│   │   ├── (dashboard)/              # Роуты дашборда
│   │   ├── (site)/                   # Роуты лендинга и документации
│   │   ├── error.stories.tsx         # stories для error.tsx
│   │   ├── error.tsx                 # Fallback UI для обработки ошибок
│   │   ├── layout.tsx                # Корневой макет приложения
│   │   ├── not-found.stories.tsx     # stories для not-found.tsx
│   │   ├── not-found.tsx             # Страница 404
│   │   ├── SWRProvider.tsx           # Провайдер для SWRConfig
│   ├── api/                          # Route Handlers
│   └── globals.css
├── auth/                             # Компоненты аутентификации
├── common/                           # Общие компоненты
├── cypress/                          # e2e тесты
├── dashboard/                        # Компоненты дашборда
├── i18n                              # Файлы конфигурации next-intl
├── icons                             # Компоненты иконок
├── markdown                          # markdown-контент для страниц: docs, privacy policy, terms of service
├── messages                          # JSON‑файлы переводов для всех локалей (ru, en)
├── mocks                             # Mock данные для storybook
├── lib/
│   ├── actions/                      # Серверные действия
│   ├── data/                         # Слой доступа к данным (DAL) и DTO-модели
│   ├── hooks/                        # Хуки
│   ├── schemas/                      # Схемы валидации (Zod)
│   ├── swr/                          # SWR хуки для работы с данными
│   ├── test-utils/                   # Утилиты для тестов
│   ├── utils/                        # Вспомогательные утилиты
│   ├── auth-client.ts                # Клиентский экземпляр библиотеки Better Auth
│   ├── auth.ts                       # Серверная конфигурация библиотеки Better Auth
│   ├── mail.ts                       # Конфигурация nodemailer для отправки писем
│   ├── permissions.ts                # Роли и права доступа
│   ├── prisma.ts                     # Инициализация Prisma Client
│   └── types.ts                      # Типы для фильтрации, сортировки и контекстов
├── prisma/                           # схема, миграции и seed-данные
├── site/                             # Компоненты лендинга и документации
├── ui/                               # UIKit компоненты
├── public/                           # Статические файлы
├── .env.development.example          # Переменные окружения для разработки
├── .env.e2e.example                  # Переменные окружения для e2e-тестов
├── .env.integration.example          # Переменные окружения для интеграционных тестов
├── .env.production.example           # Переменные окружения для production
├── cypress.config.ts
├── docker-compose.e2e.yml            # Docker Compose для e2e-тестов
├── docker-compose.integration.yml    # Docker Compose для интеграционных тестов
├── docker-compose.production.yml     # Docker Compose для production
├── Dockerfile                        # Dockerfile для приложения
├── Dockerfile.dbinit                 # Dockerfile для инициализации базы данных
├── Dockerfile.storybook              # Dockerfile для Storybook
├── middleware.ts
├── next.config.ts
├── prisma.config.ts
├── vitest.config.ts
├── vitest.setup.integration.ts       # Настройка Vitest для интеграционных тестов
└── vitest.setup.ui.ts                # Настройка Vitest для UI-тестов

Для маршрутизации используется Next.js App Router. Папка app содержит только маршруты приложения, а динамический сегмент [locale] обеспечивает маршрутизацию с учетом выбранной локали. Для всех маршрутов используется общий корневой layout.

Маршруты основных частей приложения (auth, dashboard и site) организованы с помощью Route Groups, что позволяет использовать для каждой из них независимый layout.

Структура папки app выглядит следующим образом:

├── app/
│   ├── [locale]/
│   │   ├── (auth)/                     # Роуты аутентификации
│   │   │   ├── ...
│   │   │   └── layout.tsx
│   │   │
│   │   ├── (dashboard)/                # Роуты дашборда
│   │   │   ├── ...
│   │   │   ├── companies/
│   │   │   ├── customers/
│   │   │   ├── dashboard/
│   │   │   ├── positions/
│   │   │   ├── project-categories/
│   │   │   ├── projects/
│   │   │   ├── task-categories/
│   │   │   ├── team/
│   │   │   ├── tasks/
│   │   │   ├── DashboardLayout.tsx
│   │   │   ├── error.stories.tsx
│   │   │   ├── error.tsx
│   │   │   └── layout.tsx
│   │   │
│   │   ├── (site)/                     # Роуты лендинга и документации
│   │   │   ├── ...
│   │   │   ├── layout.tsx
│   │   │   └── SiteLayout.tsx
│   │   │
│   │   ├── [...rest]/
│   │   │   └── page.tsx
│   │   │
│   │   ├── error.stories.tsx
│   │   ├── error.tsx
│   │   ├── layout.tsx
│   │   ├── not-found.stories.tsx
│   │   ├── not-found.tsx
│   │   ├── SWRProvider.tsx
│   ├── api/
│   └── globals.css

Папка (dashboard) содержит основную часть приложения и разделена по основным сущностям приложения (tasks, projects, companies, customers и другим). Все сущности имеют схожую структуру маршрутов.

Например, структура папок для маршрутов задач выглядит следующим образом:

tasks/
├── (tasks)/                            # Route Group для маршрута /tasks
│   ├── loading.tsx                     # Отображается только при загрузке /tasks
│   ├── page.tsx
│   ├── TasksPage.stories.tsx
│   └── TasksPage.tsx
├── [id]/                               # Динамический сегмент /tasks/[id]
│   ├── loading.tsx                   
│   ├── not-found.tsx                   # 404 для несуществующей задачи
│   ├── page.tsx
│   ├── TaskDetailPage.stories.tsx
│   └── TaskDetailPage.tsx

Папка (dashboard)/tasks/ состоит из двух маршрутов: /tasks и вложенного /tasks/[id].

Чтобы применить loading.tsx только к маршруту /tasks, файлы этого маршрута вынесены в отдельную Route Group(tasks). Благодаря этому файл tasks/(tasks)/loading.tsx отображается только для страницы списка задач и не влияет на маршрут /tasks/[id].

Для маршрута /tasks/[id] используется собственный loading.tsx, а также not-found.tsx, который отображает страницу 404, если задача с указанным id не существует.

Все остальные неизвестные маршруты перехватываются:

  • либо app/[locale]/not-found.tsx,

  • либо app/[locale]/[...rest]/page.tsx, который вызывает notFound() и обрабатывает любые неизвестные пути внутри сегмента [locale].

Такой подход рекомендуется при использовании next-intl, поскольку он гарантирует корректное отображение локализованной страницы 404 для всех неизвестных маршрутов.

error.tsx используется для обработки ошибок внутри соответствующего сегмента маршрута:

  • app/[locale]/(dashboard)/error.tsx — обработчик ошибок только для маршрутов дашборда.

  • app/[locale]/error.tsx — глобальный обработчик ошибок для всех остальных маршрутов.

Остальные файлы расположены за пределами папки app и предназначены для организации компонентов, тестов, слоя DAL, серверных функций и других частей приложения. Они будут описаны далее.

Работа с данными (DAL)

Файлы для работы с DAL находятся в папке lib/data/ и распределены по отдельным сущностям.

lib/
├── ...
├── data/
│   ├── comment/
│   ├── company/
│   ├── customer/
│   ├── position/
│   ├── project/
│   ├── projectCategory/
│   ├── searchKeyword/
│   ├── subtask/
│   ├── task/
│   ├── taskCategory/
│   ├── user/
│   └── utils/

Каждая сущность содержит отдельную папку __tests__ для тестов, а также два файла (task.dal.ts, task.dto.ts):

lib/
├── ...
├── data/
│   ├── ...
│   ├── task/
│   │   ├── __tests__
│   │   ├── task.dal.ts
│   │   └── task.dto.ts

Разделение DAL и DTO позволяет использовать DTO в клиентском коде без импорта серверного кода из ./generated/prisma/client, который используется в файлах *.dal.ts.

Работа с данными осуществляется через слой доступа к данным DAL — серверный код, который отвечает за проверку авторизации, получение данных, а также формирование DTO. DAL обеспечивает централизованную логику авторизации и выполнения запросов к базе данных через Prisma ORM.

На схеме ниже показан процесс взаимодействия между сервером, DAL, Better Auth и Prisma ORM при работе авторизованного пользователя.

Сервер отправляет запрос на получение данных в DAL, который проверяет авторизацию пользователя через Better Auth. Затем DAL выполняет запрос к базе данных через Prisma ORM, получает результат и возвращает DTO.

Существует несколько сценариев работы с данными.

Загрузка данных в серверных компонентах

Загрузка данных в серверных компонентах ограничена компонентами-контейнерами и файлами маршрутов (layout.tsx, page.tsx), которые, в свою очередь, загружают данные через DAL.

На диаграмме ниже представлена последовательность действий, выполняемых при переходе пользователя на защищённую страницу приложения. В процессе загрузки страницы последовательно выполняются проверка доступа, валидация входных данных, получение данных из базы данных и рендеринг страницы на сервере.

Процесс загрузки страницы состоит из следующих этапов:

  • Запрос пользователя сначала поступает в middleware, где выполняется предварительная проверка авторизации на основе данных сессии, сохранённых в cookie. Если пользователь не авторизован, происходит перенаправление на страницу входа без дальнейшей обработки запроса.

  • Если предварительная проверка пройдена успешно, запрос передаётся компоненту страницы (page.tsx), где выполняется проверка авторизации с использованием данных сессии, полученных из базы данных.

  • После успешной авторизации выполняется валидация параметров запроса (searchParams) с помощью библиотеки Zod.

  • Затем из слоя доступа к данным (DAL) запрашиваются необходимые данные. DAL выполняет запрос к базе данных через Prisma ORM и возвращает DTO, необходимые для отображения страницы.

  • После загрузки всех необходимых данных выполняется рендеринг страницы на сервере.

  • Браузер отображает готовую HTML-страницу, после чего выполняются согласование деревьев сервера и клиента (RSC Payload) и гидратация клиентских компонентов.

Изменение данных в серверных функциях

Изменение данных осуществляется с помощью Server Functions — асинхронных функций, выполняемых на сервере и вызываемых с клиента с помощью сетевого запроса. В серверных функциях выполняются проверка авторизации, валидация входных данных и операции через DAL.

На диаграмме ниже показан процесс отправки формы в приложении. В процессе отправки выполняются проверка авторизации, валидация данных, запрос к базе данных и обновление пользовательского интерфейса.

Процесс отправки формы включает следующие этапы:

  • Пользователь заполняет и отправляет форму.

  • Перед отправкой выполняется клиентская валидация введённых данных.

  • После успешной клиентской валидации форма вызывает соответствующую серверную функцию (Server Action), передавая данные формы (FormData).

  • На сервере выполняется проверка авторизации с использованием данных сессии, полученных из базы данных.

  • После успешной проверки авторизации выполняется валидация данных формы (FormData) с помощью библиотеки Zod.

  • Затем сервер обращается к слою доступа к данным (DAL), который выполняет необходимые операции с базой данных и возвращает результат выполнения запроса.

  • По завершении операции серверная функция возвращает объект ActionState, содержащий информацию о результате выполнения. В случае успешного выполнения объект содержит статус success и сообщение (message), предназначенное для отображения пользователю. При возникновении ошибки возвращаются соответствующий статус и описание причины ошибки.

  • После получения ответа клиент обновляет пользовательский интерфейс: закрывает модальное окно, отображает уведомление (Toast) с результатом выполнения операции и обновляет данные страницы без её полной перезагрузки.

Загрузка данных в клиентских компонентах

Загрузка данных в клиентских компонентах выполняется с использованием библиотеки SWR. Вся логика авторизации, валидации и взаимодействия с DAL находится в соответствующем обработчике запроса в файле route.ts.

Процесс загрузки данных клиентскими компонентами с использованием библиотеки SWR включает следующие этапы:

  • Клиентский компонент отправляет запрос на соответствующий API-маршрут.

  • В обработчике запроса выполняется проверка авторизации с использованием данных сессии, полученных из базы данных.

  • После успешной проверки авторизации выполняется валидация данных с помощью библиотеки Zod.

  • Затем из слоя доступа к данным (DAL) запрашиваются необходимые данные. DAL выполняет запрос к базе данных через Prisma ORM и возвращает DTO.

  • Обработчик возвращает клиенту ответ, содержащий данные DTO в формате JSON.

Аутентификация и авторизация

В качестве системы аутентификации была использована библиотека Better Auth. Она предоставляет полный набор функций «из коробки» и включает экосистему плагинов, упрощающую добавление расширенных возможностей.

Интеграция Better Auth проста и подробно описана в официальной документации. Она включает установку необходимых пакетов, создание API-маршрута, подключение Prisma, настройку переменных окружения и выполнение миграций базы данных.

На данный момент аутентификация реализована только по email и паролю и поддерживает следующий функционал:

  • вход / регистрация по электронной почте;

  • смена пароля;

  • восстановление пароля.

Для проверки авторизации пользователей в приложении используются два способа:

  • Optimistic check. Проверка авторизации с использованием данных сессии, хранящихся в cookie-файле. Эта проверка не является безопасной, подходит для быстрых операций и выполняется в middleware. В приложении данный способ используется для перенаправления неавторизованных пользователей на страницу входа. В Better Auth для проверки наличия сессионного cookie используется функция getSessionCookie.

// ...

// Маршруты, доступные только авторизованным пользователям
const protectedRoutes = [
  { type: "exact", path: "/dashboard" },
  { type: "prefix", path: "/companies" },
  { type: "prefix", path: "/customers" },
  { type: "prefix", path: "/positions" },
  { type: "prefix", path: "/project-categories" },
  { type: "prefix", path: "/projects" },
  { type: "prefix", path: "/task-categories" },
  { type: "prefix", path: "/tasks" },
  { type: "prefix", path: "/team" },
] as const;

// ...

export async function middleware(request: NextRequest) {
  const { pathname } = request.nextUrl;

  // Проверка доступа к защищенным маршрутам
  if (isProtectedRoute(pathname)) {
    // Получение сессии из cookie
    const sessionCookie = getSessionCookie(request);

    // Перенаправление на страницу входа при отсутствии сессии
    if (!sessionCookie) {
      return NextResponse.redirect(new URL("/sign-in", request.url));
    }
  }

  return handleI18nRouting(request);
}

// ...
  • Secure. Проверка авторизации с использованием данных сессии, сохранённых в базе данных. Такие проверки безопасны и используются в серверных компонентах, серверных функциях, обработчиках маршрутов и функциях DAL. В Better Auth для получения объекта сессии из базы данных используется функция auth.api.getSession.

import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { redirect } from "next/navigation";

export default async function DashboardPage() {
  // Получение сессии из базы данных
  const session = await auth.api.getSession({
    headers: await headers()
  })

  // Проверка наличия активной сессии
  if(!session) {
    redirect("/sign-in")
  }
    
  // ...
}

Кроме этого, для управления пользователями в приложении используется плагин Admin, который предоставляет функции для управления пользователями. Он позволяет выполнять различные операции, такие как:

  • создание пользователей;

  • удаление пользователей;

  • управление ролями пользователей;

  • управление разрешениями на основе ролей.

Для управления доступом в зависимости от роли пользователя в файле lib/permissions.ts описаны правила контроля доступа. Разрешения определяют доступные действия над сущностями приложения (task, project, customer), а роли объединяют наборы разрешений и назначаются пользователям.

import { createAccessControl } from "better-auth/plugins/access";

// Ресурсы и доступные для них разрешения
const statements = {
  project: ["create", "update", "delete"],
  task: ["create", "update", "delete"],
  subtask: ["create", "update", "delete"],
  comment: ["create", "update", "delete"],
  customer: ["create", "update", "delete"],
  user: ["create", "update", "reset-password", "change-password", "delete"],
  company: ["create", "update", "delete"],
  position: ["create", "update", "delete"],
  projectCategory: ["create", "update", "delete"],
  taskCategory: ["create", "update", "delete"],
};

export const ac = createAccessControl(statements);

// Роль администратора с полным набором разрешений
export const admin = ac.newRole(statements);

// Роль владельца с полным набором разрешений
export const owner = ac.newRole(statements);

// Роль пользователя с ограниченными разрешениями
export const user = ac.newRole({
  ...statements,
  user: ["update", "change-password"],
});

// Роль гостя без разрешений
export const guest = ac.newRole({
  project: [],
  task: [],
  subtask: [],
  comment: [],
  customer: [],
  user: [],
  company: [],
  position: [],
  projectCategory: [],
  taskCategory: [],
});

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

export const createTask = async (input: CreateTaskInputDTO) => {
  // Проверка авторизации
  const {
    user: { id: userId, workspaceId },
  } = await requireSession();

  // Проверка разрешений пользователя
  const permission = await auth.api.userHasPermission({
    body: {
      userId: userId,
      permission: {
        task: ["create"],
      },
    },
  });

  // Отказ в доступе при отсутствии разрешения
  if (!permission.success) {
    throw new AccessDeniedError("You do not have permission to create task.");
  }

  // ...
};

На данный момент система аутентификации имеет ограниченный функционал.

В ближайшее время планируется обновление Better Auth до последней версии. Текущая версия (1.4.5) не поддерживает плагин i18n, поэтому сообщения и ошибки, возвращаемые библиотекой во время аутентификации, представлены только на английском языке.

Также планируется интеграция плагина Organization, который позволяет управлять организациями, приглашать пользователей, управлять участниками, их ролями и правами доступа.

Кроме того, планируется добавить возможность входа через внешних OAuth-провайдеров, таких как VK, Яндекс и другие.

Валидация данных

Для валидации данных использовалась библиотека Zod. Валидация выполняется для параметров запроса на страницах, данных форм в серверных функциях, а также в обработчиках маршрутов.

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

Переиспользуемые схемы Zod находятся в папке lib/schemas/: в файле base.ts расположены общие схемы и утилиты, а в остальных файлах — схемы валидации, специфичные для отдельных сущностей приложения (user, task, project, customer и т.д.).

schemas/
├── base.ts
├── comment.ts
├── company.ts
├── customer.ts
├── position.ts
├── project.ts
├── projectCategory.ts
├── subtask.ts
├── task.ts
├── taskCategory.ts
└── user.ts

Например, для валидации параметров запроса страницы списка задач (/tasks) используется следующая схема:

const searchParamsSchema = z.object({
  // Поисковый запрос
  query: searchQueryParam,
  // Номер страницы
  page: pageSearchParam,
  // Количество элементов на странице
  pageSize: pageSizeSearchParam,
  // Начальная дата срока выполнения задачи
  deadlineFrom: dateSearchParam,
  // Конечная дата срока выполнения задачи
  deadlineTo: dateSearchParam,
  // Фильтр только по задачам текущего пользователя
  onlyMyTasks: booleanSearchParam,
  // Сортировка (дата создания по умолчанию)
  sort: z.enum(taskSortFields).catch("createdAt"),
  // Фильтр по статусам задач
  statuses: z.preprocess(
    searchParamToArray,
    z.array(taskStatus).optional().catch(undefined),
  ),
  // Фильтр по категориям задач
  categoryIds: z.preprocess(
    searchParamToArray,
    z.array(taskCategoryId).optional().catch(undefined),
  ),
  // Фильтр по проектам
  projectIds: z.preprocess(
    searchParamToArray,
    z.array(projectId).optional().catch(undefined),
  ),
  // Фильтр по исполнителям
  assigneeIds: z.preprocess(
    searchParamToArray,
    z.array(userId).optional().catch(undefined),
  ),
});

Данная схема используется для валидации параметров поиска, фильтрации и сортировки задач. Она объединяет общие схемы из lib/schemas/base.ts и схемы отдельных сущностей.

Общие схемы параметров поиска определены в lib/schemas/base.ts:

import z from "zod";

// Схемы параметров поиска
export const searchQueryParam = z
  .string()
  .trim()
  .max(255)
  .optional()
  .catch(undefined);

// Схема булевых параметров
export const booleanSearchParam = z.stringbool().optional().catch(undefined);

// Схема пагинации
export const pageSearchParam = z.coerce.number().int().positive().catch(1);

export const pageSizeSearchParam = z.coerce
  .number()
  .int()
  .min(1)
  .max(100)
  .catch(20);

// Преобразование значений в массив
export const searchParamToArray = (val: unknown) => {
  if (typeof val === "string") return [val];
  if (Array.isArray(val)) return val;
  return undefined;
};

// Схема даты
export const dateSearchParam = z.iso.date().optional().catch(undefined);

// Преобразование пустых значений
export const emptyStringToUndefined = (v: unknown) =>
  typeof v === "string" && v === "" ? undefined : v;

export const emptyStringToNull = (v: unknown) =>
  typeof v === "string" && v === "" ? null : v;

Другой пример использования — валидация данных формы (FormData) в серверной функции createTask.ts:

const schema = z.object({
  title: taskTitle,
  description: z.preprocess(emptyStringToUndefined, taskDescription.optional()),
  deadline: taskDeadline,
  status: taskStatus,
  projectId: z.preprocess(emptyStringToUndefined, projectId.optional()),
  categoryId: z.preprocess(emptyStringToUndefined, taskCategoryId.optional()),
  assigneeId: z.preprocess(emptyStringToUndefined, userId.optional()),
});

Эта схема также собирается из переиспользуемых схем, определённых в lib/schemas/task.ts, lib/schemas/project.ts, lib/schemas/taskCategory.ts и lib/schemas/user.ts.

Например, файл lib/schemas/task.ts содержит схемы для полей задачи:

import z from "zod";
import { TaskStatus } from "@/generated/prisma/enums";

export const taskId = z.coerce.number().int().positive();
export const taskTitle = z.string().trim().min(1).max(255);
export const taskDescription = z.string().trim().min(1).max(5000);
export const taskDeadline = z.iso.date();
export const taskStatus = z.enum(TaskStatus);

Аналогичным образом определены схемы для связанных сущностей: проекта, категории задачи и пользователя.

import z from "zod";
import { ProjectStatus } from "@/generated/prisma/enums";

export const projectId = z.coerce.number().int().positive();
//...
import z from "zod";

export const taskCategoryId = z.coerce.number().int().positive();
//...
import z from "zod";

export const userId = z.string().trim().min(1).max(255);
//...

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

Компоненты

Все компоненты приложения распределены по разным папкам в зависимости от их назначения. Базовые переиспользуемые компоненты находятся в папке ui/ и образуют UI Kit.

├── ui/
│   ├── Badge/
│   ├── BottomSheet/
│   ├── Breadcrumbs/
│   ├── Button/
│   ├── Checkbox/
│   ├── CheckboxGroup/
│   ├── DatePicker/
│   ├── Dialog/
│   ├── Disclosure/
│   ├── Link/
│   ├── Menu/
│   ├── Modal/
│   ├── ProgressBar/
│   ├── SearchField/
│   ├── Select/
│   ├── SideSheet/
│   ├── Skeleton/
│   ├── Switch/
│   ├── TextField/
│   ├── Toast/
│   ├── ToggleButtonGroup/
│   ├── Field.tsx
│   ├── I18nProvider.tsx
│   ├── Popover.tsx
│   ├── RouterProvider.tsx
│   ├── Separator.tsx
│   └── styles.ts

UI Kit построен на основе React Aria — библиотеки нестилизованных React-компонентов и хуков, которые обеспечивают логику, поведение и доступность компонентов, а их внешний вид полностью определяется разработчиком.

Для стилизации компонентов использовался Tailwind CSS. Помимо него в проекте используется библиотека Tailwind Variants, которая позволяет использовать варианты для создания нескольких версий одного и того же компонента. В качестве примера готового UI Kit в репозитории react-spectrum доступен Starter Kit, в котором используются перечисленные выше библиотеки.

Остальные компоненты системы построены на основе компонентов UI Kit и находятся в папках common/, dashboard/ и site/. В папке common/ расположены общие компоненты, используемые в различных частях приложения. Основная часть компонентов находится в папке dashboard/. В папке site/ расположены компоненты для страниц лендинга и документации.

├── common/
├── dashboard/
│   ├── comments/
│   ├── common/
│   ├── company/
│   ├── customer/
│   ├── layout/
│   ├── position/
│   ├── projectCategory/
│   ├── projects/
│   ├── search/
│   ├── subtasks/
│   ├── taskCategory/
│   ├── tasks/
│   └── users/
├── site/									
│   ├── common/
│   ├── docs/
│   ├── home/
│   └── layout/

Тесты

В проекте использовались unit-, интеграционные и e2e-тесты. Для unit- и интеграционных тестов применялся Vitest, для e2e-тестов — Cypress. Дополнительно для интеграционных и e2e-тестов использовался Docker, обеспечивающий изоляцию тестовой базы данных.

Unit тесты

Основная часть unit-тестов покрывает отдельные компоненты UI Kit и проверяет их поведение в изоляции.

  • рендеринг компонентов с различными пропсами;

  • проверка элементов формы в разных состояниях;

  • проверка отправки формы и блокировки отправки при ошибках;

  • обработка пользовательских событий;

  • проверка доступности и корректной работы с клавиатурой.

Интеграционные тесты

Интеграционные тесты использовались для проверки функций DAL.

  • проверка CRUD-операций;

  • проверка структуры DTO и корректности возвращаемых данных;

  • обработка ошибок авторизации, валидации и ограничений доступа по ролям;

  • проверка корректности поиска, фильтрации, сортировки и пагинации.

Так как все интеграционные тесты предназначены для проверки DAL (хотя это не является обязательным требованием), они расположены в папках lib/data/*/__tests__, где * соответствует конкретной сущности. Например, для task структура выглядит следующим образом:

lib/
├── ...
├── data/
│   ├── ...
│   ├── task/
│   │   ├── __tests__
│   │   │   ├── createTask.test.ts
│   │   │   ├── deleteTasks.test.ts
│   │   │   ├── getTaskCount.test.ts
│   │   │   ├── getTaskDetail.test.ts
│   │   │   ├── getTaskFormData.test.ts
│   │   │   ├── getTaskList.test.ts
│   │   │   ├── getTaskSummary.test.ts
│   │   │   ├── updateTask.test.ts
│   │   │   └── updateTaskStatuses.test.ts
│   │   ├── task.dal.ts
│   │   └── task.dto.ts

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

Для выполнения интеграционных тестов требуется база данных, содержащая тестовые данные. Одним из способов организации такой среды является использование Docker для изоляции тестовой базы данных. Для заполнения тестовой базы данных используется файл prisma/test-seed.ts. Основные тестовые данные вынесены в файл prisma/seed/test-data.ts, что позволяет использовать один и тот же набор данных в разных тестах, избегая дублирования и упрощая их сопровождение. Подробная инструкция по настройке и запуску интеграционных тестов с использованием Prisma и Docker приведена в документации Prisma.

В примере ниже показана подготовка тестовых данных и их использование для проверки успешного создания задачи.

import {
  users,
  positions,
  companies,
  customers,
  workspaces,
  taskCategories,
  projectCategories,
  projects,
} from "@/prisma/seed/test-data";

import { seed } from "@/prisma/test-seed";
import { resetDatabase } from "@/lib/test-utils/resetDatabase";

// ...

describe("createTask", () => {
  beforeAll(async () => {
    // Авторизация тестового пользователя
    (requireSession as any).mockResolvedValue({
      user: { id: "user-1", workspaceId: 1 },
    });

    // Очистка базы данных
    await resetDatabase();

    // Заполнение тестовыми данными
    await seed({
      workspaces,
      positions,
      users,
      companies,
      customers,
      taskCategories,
      projectCategories,
      projects,
    });
  });

  // ...

  it("should successfully create a task", async () => {
    const result = await createTask({
      title: "New Task",
      status: TaskStatus.active,
      projectId: 1,
      categoryId: 1,
      assigneeId: "user-2",
      deadline: "2025-12-31",
    });

    expect(result).toBeDefined();
    expect(result.workspaceId).toBe(1);
    expect(result.creatorId).toBe("user-1");
  });

  // ...
});

e2e тесты

e2e-тесты использовались для проверки основных сценариев работы пользователей с системой.

  • создание, редактирование и удаление сущностей через формы интерфейса;

  • корректное отображение сообщений об ошибках при вводе невалидных данных (ошибки валидации полей формы, toast-уведомления);

  • проверка доступа и ограничений в зависимости от роли пользователя;

  • работа со списками: поиск, фильтрация, сортировка и пагинация.

e2e-тесты находятся в папке cypress:

cypress/
├── e2e/
│   ├── comments/
│   ├── company/
│   ├── customers/
│   ├── dashboard/
│   ├── positions/
│   ├── project-categories/
│   ├── projects/
│   ├── subtasks/
│   ├── task-categories/
│   ├── tasks/
│   │   ├── create-task.cy.ts
│   │   ├── delete-task.cy.ts
│   │   ├── delete-tasks.cy.ts
│   │   ├── filter-tasks.cy.ts
│   │   ├── sort-tasks.cy.ts
│   │   └── update-task.cy.ts
│   └── users/
├── support/
│   ├── commands
│   │   ├── auth.ts
│   │   ├── components.ts
│   │   ├── flow.ts
│   │   ├── selectors.ts
│   ├── e2e.ts
│   └── index.d.ts

Как и для интеграционных тестов, каждый отдельный сценарий e2e-тестирования размещается в отдельном файле. Для изоляции тестовой базы данных используется Docker. Заполнение тестовой базы выполняется с помощью файла prisma/test-seed.ts, а основные тестовые данные вынесены в файл prisma/seed/test-data.ts.

Для заполнения и сброса базы данных в Cypress используется функция cy.task(), которая предоставляет возможность выполнения произвольного кода Node.js.

Задачи db:seed и db:reset определяются в функции setupNodeEvents файла конфигурации cypress.config.ts:

import { defineConfig } from "cypress";
import { seed } from "./prisma/test-seed";
import { resetDatabase } from "./lib/test-utils/resetDatabase";

export default defineConfig({
  e2e: {
    experimentalRunAllSpecs: true,
    defaultCommandTimeout: 10000,
    baseUrl: "http://localhost:3000",
    setupNodeEvents(on) {
      on("task", {
        async "db:reset"() {
          await resetDatabase();
          return null;
        },

        async "db:seed"(payload) {
          await seed(payload);
          return null;
        },
      });
    },
  },
});

В примере ниже показана подготовка тестовых данных и их использование для проверки сценария успешного создания задачи.

import {
  users,
  projects,
  accounts,
  positions,
  companies,
  customers,
  workspaces,
  taskCategories,
  projectCategories,
} from "@/prisma/seed/test-data";

describe("Task creation", () => {
  // Данные задачи, которые будут использоваться при заполнении формы
  const taskData = {
    title: "Created Task Title",
    description: "Created Task Description",
    deadline: {
      day: "01",
      month: "01",
      year: "2030",
    },
    statusKey: "active",
    categoryKey: "1",
    projectKey: "1",
    assigneeKey: "user-1",
  };

  beforeEach(() => {
    cy.viewport(1440, 900);

    // Подготовка данных для заполнения тестовой базы
    const payload = {
      workspaces,
      users,
      accounts,
      positions,
      companies,
      customers,
      projectCategories,
      taskCategories,
      projects,
    };

    // Очистка базы данных и подготовка тестовых данных
    cy.task("db:reset");
    cy.task("db:seed", payload);

    // Вход пользователя и переход на страницу задач
    cy.signIn("user-1@test.com", "12345abc");
    cy.visit("/en/tasks");
  });

  it("creates a new task with valid data", () => {
    // Открытие формы создания задачи
    cy.getByData("tasks-empty-section-create-button").click();

    // Заполнение формы данными задачи
    cy.fillTaskForm(taskData);

    // Отправка формы
    cy.get('button[type="submit"]').click();

    // Проверка отображения созданной задачи в списке
    cy.getByData("entity-grid").within(() => {
      cy.contains(taskData.title);
      cy.contains(taskData.deadline.year);
      cy.contains(/active/i);
      cy.contains("Project 1");
      cy.contains("Task Category 1");
      cy.contains("User 1");
    });
  });

  // ...
});

Полезные ссылки

Итог

На этом всё. Надеюсь, материал оказался интересным и полезным. Конечно, статья охватывает не все аспекты разработки, однако в ней собраны основные подходы и решения, которые были использованы при создании проекта.

За рамками статьи остался Storybook. В настоящее время он используется только для изолированной разработки UI-компонентов, а документация компонентов и примеры их использования ещё находятся в процессе подготовки. Кроме того, некоторые подходы по его использованию могут быть переосмыслены и изменены в дальнейшем.

Ссылки