Создание заявки через Server Action
Оглавление курса · Скачать все состояния проекта.
Учебная задача: Проверить ввод, назначить владельца и вернуть понятное состояние формы.
Входное состояние: Определены действующая личность, роли и разрешённые выборки. Выходное состояние: Серверное действие создаёт открытую заявку текущего пользователя. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-16 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.
Исходники подготовлены редакционным чтением. Пакеты, сервер, сборка, миграции, примеры и внешнее размещение не выполнялись. Ответы страницы и изменения данных ниже являются ожидаемыми, example_execution: not_run.
Сотрудник должен создавать заявку без знания строки подключения и структуры SQL. Интерфейс собирает заголовок и описание, а серверное действие проверяет личность, данные и правила записи. Владелец назначается по сессии, статус начинается с open. Эта последовательность важна: форма не получает право выбирать чужого владельца только потому, что может отправить произвольный набор полей.
Файл lib/actions.ts начинается с use server. Экспортируемые функции являются серверными действиями и могут вызываться через предусмотренный механизм Next. Это не означает, что действие автоматически доверяет любому вызывающему клиенту. Его параметры остаются входной границей. Прямое обращение к функции должно пройти ту же проверку пользователя и значений, что и обычное нажатие кнопки.
Сначала личность и допустимые поля
saveInput вызывает requireActor(), затем проверяет неизвестное значение через Zod. Схема принимает только заголовок и описание, убирает внешние пробелы и ограничивает длину. Минимум заголовка восемь символов, описания десять; максимум соответственно 160 и 5000. Это конкретные учебные требования мастерской. Они не обещают определить качество текста, но дают ясную границу для хранения и обратной связи.
При отказе safeParse возвращаем FormState с общим сообщением и ошибками полей. Нормальный отказ валидации не обязан становиться исключением всего маршрута. Пользователь может исправить конкретное поле. Ошибка соединения базы, напротив, не является просьбой написать более длинный заголовок. В данном действии неожиданный отказ записи не скрыт внутри ложного сообщения о неправильном вводе.
Очищенные title и body передаются в insert. ownerId добавляется из actor.userId, status явно задаётся open. Даже если исходное значение содержало поле ownerId, оно не управляет этой строкой. Явное присваивание защищает предметное правило и показывает его читателю. Начальный статус также не берётся из hidden input, который посетитель мог бы изменить.
Подпись useActionState
Клиентский TicketForm использует useActionState. Действие получает предыдущий FormState первым параметром и FormData вторым. Эта подпись отличается от функции, которая напрямую используется в обычной форме без такого состояния. Если забыть первый параметр, сервер будет пытаться читать поля не из того значения. В нашем действии предыдущий результат не нужен для записи, но место параметра сохранено.
FormData может содержать строки или файлы и не гарантирует нужные имена. Мы явно извлекаем title и body, передавая результат проверке Zod. Тип поля HTML не является серверным доказательством. Атрибуты required, minLength и maxLength помогают обычному браузеру дать раннюю обратную связь, но схема повторяет обязательное правило на сервере независимо от клиента.
Состояние формы возвращается после действия и показывается в role="status". Ошибки полей связаны с input через aria-describedby. Человек должен понять, относится ли замечание к заголовку или описанию. Кнопка отправки выделена в дочерний Submit, где useFormStatus() видит окружающую форму. Вызов hook в компоненте, который ещё только создаёт эту форму, не получил бы её статус тем же способом.
Отправка и свежий список
Пока отправка ожидается, кнопка отключена и сообщает «Сохраняем…». Это снижает случайное повторное нажатие, но не обеспечивает идемпотентность операции. Два отдельные запроса могут создать две строки. Для строгого запрета повторов потребовался бы отдельный ключ операции и ограничение хранения. Не приписываем pending-флагу свойство, которого в серверном договоре нет.
После записи вызывается revalidatePath("/tickets"). Это сообщает Next, что связанный маршрут должен получить актуальные данные согласно его механизму обновления. Сам insert уже изменил базу; инвалидирование относится к отображению. Не заменяем один шаг другим. Возврат сообщения «создана» без фактического insert был бы ложным успехом, а отсутствие инвалидирования могло бы оставить устаревшее представление списка.
У действия есть также вариант createTicketFromValues, который принимает unknown и использует тот же внутренний путь сохранения. Следующий урок применит его к форме React Hook Form. Ссылка создания выполняет полную загрузку, чтобы динамический перехват карточки не обработал служебное слово new как идентификатор. Так два интерфейса не получают разные правила владельца или длины текста. Повторное использование серверной проверки полезнее копирования похожих условий в каждый клиентский компонент.
Разбор ожидаемых отправок
Вошедший сотрудник отправляет корректные значения: ожидается новая собственная открытая заявка. Короткий заголовок даёт ошибку поля и не должен создать строку. Дополнительное поле владельца не меняет серверное присваивание. Анонимный вызов требует входа до записи. Эти случаи можно проследить по действию; база и форма в этой работе не запускались.
После отказа сети клиент может не знать, успела ли база принять запись. Повторная отправка тогда требует осмысленного решения, а не автоматического обещания «ничего не сохранилось». В следующей форме это ограничение появится в сообщении об ошибке. Для самостоятельного разбора отметьте каждую границу: HTML, FormData, Zod, личность, insert и обновление маршрута. Вместе они описывают одну предметную операцию, сохраняя разные обязанности.
Проверьте смысл границ
Действие использует только очищенные поля схемы. Значение title после trim может отличаться от строкового ввода с внешними пробелами. Поэтому успешная запись определяется серверным результатом проверки, а не буквальным копированием всех значений FormData. Такой подход сохраняет компактный договор между формой и моделью.
Создание собственной заявки и просмотр чужой — разные полномочия. Новый ownerId берётся из текущего пользователя даже для менеджера, пока проект не реализовал отдельную форму назначения исполнителя. Не переносим право общего чтения в способность произвольно присваивать любого владельца. Дополнительное назначение потребовало бы самостоятельной проверки и интерфейса.
Полные файлы этого шага
Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.
lib/validation.ts
import { z } from "zod";
export const ticketInput = z.object({
title: z.string().trim()
.min(8, "Не менее восьми символов")
.max(160, "Не более 160 символов"),
body: z.string().trim()
.min(10, "Добавьте описание от десяти символов")
.max(5000, "Не более 5000 символов"),
});
export type TicketInput = z.infer<typeof ticketInput>;
export type FormState = {
ok: boolean;
message: string;
fieldErrors?: {
title?: string[];
body?: string[];
};
};
export const initialState: FormState = { ok: false, message: "" };
lib/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { db } from "@/db/client";
import { tickets } from "@/db/schema";
import { requireActor } from "@/lib/actor";
import { ticketInput } from "@/lib/validation";
import type { FormState } from "@/lib/validation";
async function saveInput(input: unknown): Promise<FormState> {
const actor = await requireActor();
const parsed = ticketInput.safeParse(input);
if (!parsed.success) {
return {
ok: false,
message: "Исправьте поля",
fieldErrors: parsed.error.flatten().fieldErrors,
};
}
await db.insert(tickets).values({
...parsed.data,
ownerId: actor.userId,
status: "open",
});
revalidatePath("/tickets");
return { ok: true, message: "Заявка создана" };
}
export async function createTicketAction(
_previous: FormState,
form: FormData,
): Promise<FormState> {
return saveInput({
title: form.get("title"),
body: form.get("body"),
});
}
export async function createTicketFromValues(
input: unknown,
): Promise<FormState> {
return saveInput(input);
}
components/ticket-form.tsx
"use client";
import { useActionState } from "react";
import { useFormStatus } from "react-dom";
import { createTicketAction } from "@/lib/actions";
import { initialState } from "@/lib/validation";
function Submit() {
const { pending } = useFormStatus();
return (
<button disabled={pending}>
{pending ? "Сохраняем…" : "Создать заявку"}
</button>
);
}
export function TicketForm() {
const [state, action] = useActionState(createTicketAction, initialState);
return (
<form action={action}>
<label htmlFor="title">Заголовок</label>
<input
id="title"
name="title"
required
minLength={8}
maxLength={160}
aria-describedby="title-error"
/>
<p id="title-error" className="error">
{state.fieldErrors?.title?.join(". ")}
</p>
<label htmlFor="body">Описание</label>
<textarea
id="body"
name="body"
required
minLength={10}
maxLength={5000}
aria-describedby="body-error"
/>
<p id="body-error" className="error">
{state.fieldErrors?.body?.join(". ")}
</p>
<Submit />
<p role="status">{state.message}</p>
</form>
);
}
app/(work)/tickets/new/page.tsx
import { requireActor } from "@/lib/actor";
import { TicketForm } from "@/components/ticket-form";
export default async function NewTicket() { await requireActor(); return <main><h1>Новая заявка</h1><TicketForm /></main>; }
Документация и исходники
Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.