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

Поиск, сортировка, страницы и обновление таблицы

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

Учебная задача: Отделить серверную разрешённую выборку от клиентской обработки ограниченного набора.

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

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

Когда заявок становится больше, карточки списка занимают много места. Диспетчеру удобна таблица с номером, заголовком и статусом, которую можно сортировать и показывать частями. Подключим TanStack Table версии 8.21.3 и сохраним серверную границу доступа. Сначала база выдаёт разрешённые строки, затем клиентский инструмент меняет их представление. Нельзя менять порядок этих этапов и передавать чужие данные ради последующего скрытия.

Используем договор v8: useReactTable, модели строк и flexRender. Современная ветка v9 имеет иной API, поэтому примеры нельзя смешивать по отдельным похожим именам. Фиксированная версия таблицы помогает читать один связанный механизм. Остальные зависимости остаются неразрешёнными диапазонами в пакете; установка и совместимость не подтверждались исполнением.

Серверный поиск и клиентский фильтр

Форма Form сохраняет q в URL и передаёт его fetchTickets(q). SQL объединяет поиск с правилом владельца и ограничивает результат двумястами строками. Это выборка базы. Выпадающий статус в таблице фильтрует только уже полученный набор. Поэтому подпись прямо говорит «в загруженном наборе». Значения двух фильтров имеют разную область и не должны изображаться одним полноценным поиском по всей базе.

Параметризованный ilike защищает структуру SQL от превращения ввода в текст команды. При этом символы шаблона сохраняют смысл совпадения, как обсуждалось раньше. Если нужна буквальная семантика, её следует реализовать отдельно. Текущий поиск остаётся внутри разрешённого набора; выбор роли и владельца не исчезает при любом q. Это главное свойство серверной границы таблицы.

Если в базе больше двухсот подходящих заявок, остальные не входят в клиентскую таблицу. Наш пример не реализует серверные offset или cursor и общий count. Нельзя подписать клиентское число страниц как полное количество всех записей редакции. Для рабочего большого списка понадобятся сортировка, фильтры и пагинация в едином серверном запросе с отдельным договором URL и результата.

Определение колонок

Колонки связаны с типом Ticket. У номера и статуса используется accessorKey, у заголовка добавлен cell со ссылкой на каноническую карточку. В useMemo хранится стабильное определение колонок; строки приходят через props после серверного чтения. Это помогает инструменту различать изменение данных и случайное создание новой конфигурации при каждом рендере.

getCoreRowModel задаёт исходную модель, затем подключаются фильтрация, сортировка и пагинация. Не получаем страницы вручную через slice поверх другой неучтённой сортировки. Инструмент применяет согласованный порядок своих моделей. flexRender отрисовывает определение заголовка или ячейки с нужным контекстом, поэтому колонка может иметь и простой текст, и функцию содержимого.

Sorting и columnFilters являются управляемым состоянием React. Изменение заголовка вызывает соответствующий toggle handler. Атрибут aria-sort сообщает направление для колонки, а кнопка позволяет управлять сортировкой с клавиатуры. Пагинация имеет размер пять и кнопки, отключённые на границах. Число показывается относительно текущего набора, что соответствует нашей честной подписи ограничения.

Опрос обновляет серверное представление

Эффект устанавливает интервал в тридцать секунд. Если вкладка видима, вызывается router.refresh(), чтобы серверная страница заново получила разрешённые строки. Скрытая вкладка не выполняет этот шаг. При размонтировании интервал очищается. Это простой периодический опрос, а не WebSocket, событие базы или обещание мгновенного появления новой заявки.

Refresh не должен автоматически расширять полномочия. Каждый новый серверный запрос проходит requireActor и условие видимости. Если роль изменилась или сессия завершилась, новый набор определяется текущим серверным договором. Состояние таблицы в браузере может сохраниться при обновлении; новая длина набора и фильтрация могут изменить доступные страницы. В реальном интерфейсе это требует внимательного наблюдения и ясного сообщения человеку.

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

Ожидаемая работа таблицы

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

Для самостоятельного разбора объясните, где происходит поиск q, где фильтр статуса и к какому количеству относится число страниц. Затем рассмотрите появление новой заявки за пределами первых двухсот строк. Этот случай показывает границу клиентской пагинации. Завершение курса зафиксирует её вместе с условиями базы, личности и выпуска, чтобы удобный интерфейс не выглядел обещанием неограниченной рабочей системы.

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

Сортировка таблицы после обновления может сохранить выбранное направление, хотя строки стали другими. Это полезно для продолжения работы, но человек должен понимать, что набор перечитан. Если объект удалён, он исчезнет после получения нового серверного результата; локальная сортировка не может вернуть его в базу.

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

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

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

components/ticket-table.tsx

"use client";

import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import {
  flexRender,
  getCoreRowModel,
  getFilteredRowModel,
  getPaginationRowModel,
  getSortedRowModel,
  useReactTable,
} from "@tanstack/react-table";
import type {
  ColumnDef,
  ColumnFiltersState,
  SortingState,
} from "@tanstack/react-table";
import type { Ticket } from "@/lib/tickets";

export function TicketTable({ rows }: { rows: Ticket[] }) {
  const columns = useMemo<ColumnDef<Ticket>[]>(() => [
    { accessorKey: "id", header: "Номер" },
    {
      accessorKey: "title",
      header: "Заголовок",
      cell: (context) => (
        <Link href={`/tickets/${context.row.original.id}`}>
          {context.getValue<string>()}
        </Link>
      ),
    },
    { accessorKey: "status", header: "Статус" },
  ], []);

  const [sorting, setSorting] = useState<SortingState>([]);
  const [columnFilters, setColumnFilters] =
    useState<ColumnFiltersState>([]);
  const table = useReactTable({
    data: rows,
    columns,
    state: { sorting, columnFilters },
    onSortingChange: setSorting,
    onColumnFiltersChange: setColumnFilters,
    getCoreRowModel: getCoreRowModel(),
    getFilteredRowModel: getFilteredRowModel(),
    getSortedRowModel: getSortedRowModel(),
    getPaginationRowModel: getPaginationRowModel(),
    initialState: { pagination: { pageSize: 5 } },
  });

  const router = useRouter();
  useEffect(() => {
    const timer = window.setInterval(() => {
      if (document.visibilityState === "visible") router.refresh();
    }, 30000);
    return () => window.clearInterval(timer);
  }, [router]);

  return (
    <>
      <label htmlFor="status-filter">Статус в загруженном наборе</label>
      <select
        id="status-filter"
        value={(table.getColumn("status")?.getFilterValue() as string) || ""}
        onChange={(event) =>
          table.getColumn("status")?.setFilterValue(event.target.value)
        }
      >
        <option value="">Все</option>
        <option value="open">Открытые</option>
        <option value="closed">Закрытые</option>
      </select>
      <table>
        <thead>
          {table.getHeaderGroups().map((group) => (
            <tr key={group.id}>
              {group.headers.map((header) => (
                <th
                  key={header.id}
                  scope="col"
                  aria-sort={
                    header.column.getIsSorted() === "asc"
                      ? "ascending"
                      : header.column.getIsSorted() === "desc"
                        ? "descending"
                        : "none"
                  }
                >
                  <button
                    disabled={!header.column.getCanSort()}
                    onClick={header.column.getToggleSortingHandler()}
                  >
                    {flexRender(
                      header.column.columnDef.header,
                      header.getContext(),
                    )}
                  </button>
                </th>
              ))}
            </tr>
          ))}
        </thead>
        <tbody>
          {table.getRowModel().rows.map((row) => (
            <tr key={row.id}>
              {row.getVisibleCells().map((cell) => (
                <td key={cell.id}>
                  {flexRender(
                    cell.column.columnDef.cell,
                    cell.getContext(),
                  )}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>
      {!table.getRowModel().rows.length && (
        <p>В этом наборе нет подходящих заявок.</p>
      )}
      <button
        disabled={!table.getCanPreviousPage()}
        onClick={() => table.previousPage()}
      >
        Назад
      </button>
      <span>
        {" Страница "}
        {table.getState().pagination.pageIndex + 1}
        {" из "}
        {Math.max(1, table.getPageCount())}
        {" "}
      </span>
      <button
        disabled={!table.getCanNextPage()}
        onClick={() => table.nextPage()}
      >
        Далее
      </button>
      <p>
        Фильтр и страницы относятся максимум к 200 загруженным строкам.
        Обновление: раз в 30 секунд в видимой вкладке.
      </p>
    </>
  );
}

app/(work)/tickets/page.tsx

import Form from "next/form";
import { fetchTickets, fetchNotice } from "@/lib/data";
import { TicketTable } from "@/components/ticket-table";

type SearchProps = {
  searchParams: Promise<{ q?: string | string[] }>;
};
export default async function Tickets({ searchParams }: SearchProps) {
  const raw = (await searchParams).q;
  const q = (typeof raw === "string" ? raw : "").trim().slice(0, 80);
  const [rows, notice] = await Promise.all([
    fetchTickets(q),
    fetchNotice(),
  ]);

  return (
    <main>
      <h1>Заявки</h1>
      <a href="/tickets/new">Создать заявку</a>
      <p>{notice}</p>
      <Form action="/tickets">
        <label htmlFor="q">Поиск в базе по заголовку</label>
        <input id="q" name="q" defaultValue={q} maxLength={80} />
        <button>Найти</button>
      </Form>
      <TicketTable rows={rows} />
    </main>
  );
}

lib/data.ts

import "server-only";

import { and, desc, eq, ilike, sql } from "drizzle-orm";
import { db } from "@/db/client";
import { tickets } from "@/db/schema";
import { requireActor } from "@/lib/actor";
import type { Actor } from "@/lib/actor";
import type { Ticket } from "@/lib/tickets";

export function visibleTo(actor: Actor) {
  return actor.role === "employee"
    ? eq(tickets.ownerId, actor.userId)
    : sql`true`;
}

export async function fetchTickets(q = ""): Promise<Ticket[]> {
  const actor = await requireActor();
  return db.select()
    .from(tickets)
    .where(and(
      visibleTo(actor),
      q ? ilike(tickets.title, `%${q.slice(0, 80)}%`) : undefined,
    ))
    .orderBy(desc(tickets.id))
    .limit(200);
}

export async function fetchNotice(): Promise<string> {
  return "Сотрудник видит свои заявки; " +
    "менеджер и администратор — общий список. " +
    "Вывод ограничен 200 строками.";
}

export async function findTicket(
  id: number,
): Promise<Ticket | undefined> {
  const actor = await requireActor();
  const [row] = await db.select()
    .from(tickets)
    .where(and(eq(tickets.id, id), visibleTo(actor)))
    .limit(1);
  return row;
}

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

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