Перейти к содержанию

Изменение статуса, удаление и оптимистический интерфейс

Оглавление курса · Скачать все состояния проекта.

Учебная задача: Повторно проверять полномочие мутации и откатывать неподтверждённое представление.

Входное состояние: Сотрудник может создать собственную заявку. Выходное состояние: Разрешённый пользователь меняет статус; admin удаляет, оптимистический статус ждёт подтверждения. Продолжаем «Смену» — учебную диспетчерскую заявок мастерской. Нужны основы JavaScript, TypeScript, React и HTTP. Используем App Router, Next 16.4.0, React 19.3.0 и Node.js не ниже 20.9.0. Основные версии закреплены; диапазоны остальных пакетов ещё не разрешены установкой и lockfile отсутствует. Полный каталог lesson-18 в архиве соответствует окончанию этой главы. Выбирайте один снимок целиком: перенос отдельных файлов из будущих этапов меняет его зависимости и правила данных.

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

Диспетчер хочет быстро закрыть выполненную заявку. Интерфейс может сразу показать предполагаемый новый статус, но постоянная запись принадлежит серверу. Добавим действие изменения, повторно проверим личность и объект, затем подключим useOptimistic. Отдельно реализуем удаление только для администратора. Эти операции имеют разные последствия и не должны получать одинаковое право только из-за общей карточки.

Начнём с серверной стороны. setStatusAction принимает unknown-параметры, получает Actor и проверяет id как положительное безопасное целое, а статус как одно из значений open/closed. Тип props клиентской кнопки помогает автору исходника, но не защищает самостоятельный вызов действия. Поэтому проверка присутствует внутри функции, до SQL-изменения.

Условие доступа входит в UPDATE

Обновление объединяет id и visibleTo(actor) в одном where. Сотрудник может изменить свою заявку, менеджер и администратор общий разрешённый набор. Это важнее предварительного чтения и последующего UPDATE только по id: условие полномочия сохраняется непосредственно в операции записи. Пустой returning означает, что подходящей строки нет в доступном наборе, и возвращает понятный отказ.

Такой договор ограничивает объект, но не вводит контроль версии. Если два допустимых пользователя одновременно меняют статус, более позднее изменение может победить. В схеме нет номера версии и сравнения исходного состояния. Оптимистический интерфейс не исправляет эту серверную границу. Для рабочей редакции конфликт потребовал бы отдельного поля или условия и осмысленной обратной связи.

После изменения инвалидируются список и конкретная карточка. В результате будущие представления должны получить новое значение по механизму Next. SQL-запись и обновление UI остаются разными шагами. Возвращаем ok только после изменения строки. При отсутствии объекта не отправляем ложный успех, даже если клиент заранее показал предполагаемый новый статус.

Временное представление в браузере

useOptimistic получает серверный status как основу и reducer, который подставляет выбранное следующее значение. При нажатии startTransition запускает асинхронный путь: сначала устанавливаем оптимистическое значение, затем вызываем действие. Кнопка сообщает «Подтверждаем…» и отключается. Отображаемый статус пока является предположением, а не доказанным состоянием базы.

Если действие вернуло отказ или бросило исключение, показываем сообщение. После завершения перехода оптимистическое представление возвращается к основной серверной величине, если новое значение не подтверждено обновлённым состоянием. При успехе вызываем router.refresh, чтобы получить свежую карточку. Это может иметь краткие переходные состояния; не обещаем визуальное отсутствие всех переключений без проверки запущенной среды.

Важна граница жизненного цикла: оптимистическое обновление находится внутри transition, а не в произвольном обработчике вне поддержанного действия. Reducer не выполняет запрос и не изменяет входной объект. Он только рассчитывает временное представление. Побочные эффекты находятся в отдельном асинхронном участке. Такая структура помогает видеть, какое действие можно безопасно повторно вычислить, а какое изменяет постоянные данные.

Удаление требует более узкого права

deleteTicketAction сначала получает действующего Actor и отклоняет любую роль кроме admin. Затем проверяет id, удаляет конкретную строку и оценивает returning. Даже менеджер, который видит общий список, не получает право удаления. В карточке DeleteButton показывается только admin, но сервер повторяет правило независимо от этого условия JSX. Самостоятельный вызов сотрудника должен получить отказ.

Кнопка требует подтверждения через простой браузерный confirm. Это помогает избежать случайного нажатия, но не является серверной авторизацией и не предоставляет возможность восстановления. В учебной схеме удаление жёсткое: строка исчезает из базы. Для мастерской может быть уместнее архивирование с inactive-флагом и журналом; такого расширения здесь нет. Поэтому текст явно говорит о необратимости результата.

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

Разберите ожидаемые случаи

Сотрудник закрывает собственную заявку: ожидается временный closed, затем серверное подтверждение. Он отправляет id чужой заявки: UPDATE не должен затронуть строку. Менеджер может менять статус общей заявки, но удаление ему отказано. Admin может удалить существующую строку; неизвестный id не превращается в удаление другой записи. Все случаи следуют из проверки и SQL-условий.

Для самостоятельного разбора найдите место, где UI только предполагает результат, и место, где база реально изменяется. Затем объясните, почему hidden input, bind-параметр или полученный в props id не доказывает доступ к объекту. Параметры удобны для передачи адреса операции, полномочия подтверждаются внутри неё. Пример не запускался, поэтому описанные изменения являются ожидаемыми.

Проверьте смысл границ

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

Удаление выполняется только после подтверждения и серверной роли admin. Диалог браузера помогает человеку заметить последствия, но его можно обойти другим клиентом. Поэтому невозможно перенести проверку роли в confirm или сделать кнопку главным доказательством права. Наличие двух видимых шагов не отменяет одну обязательную серверную проверку.

Полные файлы этого шага

Следующие файлы показаны полностью. Остальные согласованные компоненты и настройки находятся в том же каталоге архива; сохраняйте их вместе при самостоятельном прохождении.

lib/actions.ts

"use server";

import { and, eq } from "drizzle-orm";
import { visibleTo } from "@/lib/data";
import { z } from "zod";
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);
}

const ticketId = z.number().int().positive().max(Number.MAX_SAFE_INTEGER);

export async function setStatusAction(
  rawId: unknown,
  rawStatus: unknown,
): Promise<FormState> {
  const actor = await requireActor();
  const id = ticketId.safeParse(rawId);
  const status = z.enum(["open", "closed"]).safeParse(rawStatus);
  if (!id.success || !status.success) {
    return { ok: false, message: "Неверные параметры" };
  }
  const changed = await db.update(tickets)
    .set({ status: status.data })
    .where(and(eq(tickets.id, id.data), visibleTo(actor)))
    .returning({ id: tickets.id });
  if (!changed.length) {
    return { ok: false, message: "Заявка недоступна" };
  }
  revalidatePath("/tickets");
  revalidatePath(`/tickets/${id.data}`);
  return { ok: true, message: "Статус сохранён" };
}

export async function deleteTicketAction(
  rawId: unknown,
): Promise<FormState> {
  const actor = await requireActor();
  if (actor.role !== "admin") {
    return { ok: false, message: "Удаление доступно администратору" };
  }
  const id = ticketId.safeParse(rawId);
  if (!id.success) return { ok: false, message: "Неверный номер" };
  const changed = await db.delete(tickets)
    .where(eq(tickets.id, id.data))
    .returning({ id: tickets.id });
  if (!changed.length) {
    return { ok: false, message: "Заявка недоступна" };
  }
  revalidatePath("/tickets");
  return { ok: true, message: "Заявка удалена" };
}

components/status-button.tsx

"use client";

import { useOptimistic, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { setStatusAction } from "@/lib/actions";
import type { TicketStatus } from "@/lib/tickets";

type StatusButtonProps = { id: number; status: TicketStatus };
export function StatusButton({ id, status }: StatusButtonProps) {
  const [shown, optimistic] = useOptimistic(
    status,
    (_current: TicketStatus, next: TicketStatus) => next,
  );
  const [pending, startTransition] = useTransition();
  const [error, setError] = useState("");
  const router = useRouter();

  function change() {
    const next = shown === "open" ? "closed" : "open";
    setError("");
    startTransition(async () => {
      optimistic(next);
      try {
        const result = await setStatusAction(id, next);
        if (!result.ok) setError(result.message);
        else router.refresh();
      } catch {
        setError(
          "Сохранение не подтверждено; " +
          "состояние вернётся к серверному.",
        );
      }
    });
  }

  return (
    <>
      <p>Статус: {shown}</p>
      <button disabled={pending} onClick={change}>
        {pending
          ? "Подтверждаем…"
          : shown === "open" ? "Закрыть" : "Открыть"}
      </button>
      <p role="alert">{error}</p>
    </>
  );
}

components/delete-button.tsx

"use client";

import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { deleteTicketAction } from "@/lib/actions";

export function DeleteButton({ id }: { id: number }) {
  const [pending, start] = useTransition();
  const [message, setMessage] = useState("");
  const router = useRouter();

  function remove() {
    if (!window.confirm("Удалить заявку без возможности восстановления?")) {
      return;
    }
    start(async () => {
      try {
        const result = await deleteTicketAction(id);
        if (result.ok) {
          router.push("/tickets");
          router.refresh();
        } else {
          setMessage(result.message);
        }
      } catch {
        setMessage("Не удалось подтвердить удаление");
      }
    });
  }

  return (
    <>
      <button disabled={pending} onClick={remove}>Удалить заявку</button>
      <p role="alert">{message}</p>
    </>
  );
}

app/(work)/tickets/[id]/page.tsx

import { notFound } from "next/navigation";
import { findTicket } from "@/lib/data";
import { requireActor } from "@/lib/actor";
import { StatusButton } from "@/components/status-button";
import { DeleteButton } from "@/components/delete-button";
export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) {
  const raw = (await params).id; const id = Number(raw);
  if (!/^[0-9]+$/.test(raw) || !Number.isSafeInteger(id) || id <= 0) notFound();
  const ticket = await findTicket(id); if (!ticket) notFound();
  const actor = await requireActor();
  return <main><h1>{ticket.title}</h1><p>{ticket.body}</p><StatusButton id={ticket.id} status={ticket.status} />{actor.role === "admin" && <DeleteButton id={ticket.id} />}</main>;
}

Документация и исходники

Договор этой главы раскрыт в официальной документации. Сопоставьте её API с выбранной версией и с объяснённым источником каждого значения. Архив «Смены» содержит двадцать полных состояний и условия самостоятельной подготовки. Ожидаемый результат оценивается по текущей главе: поздние проверки владельца, база и внешние ключи не являются неявно выполненными предпосылками раннего учебного состояния.