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

Фильтры, сортировка и страницы каталога

Оглавление серии

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

Используем прежние статьи: «Сетка карточек CSS Grid», «Форма обратной связи», «Структура адресов библиотеки». Для будущего эксперимента опубликуйте нужные записи, сохраняя их автора и категорию. Все результаты ниже ожидаемые, поскольку приложение не запускалось. Полный серверный модуль site/cms.mjs входит в архив и будет использован отдельным сайтом.

Условие отбора

Простой фильтр имеет вид filters[field][operator]=value. Условие filters[level][$eq]=beginner оставит статьи начального уровня. Если добавить filters[category][slug][$eq]=layout, выбираются документы, отвечающие обоим условиям. Это не поиск по видимому тексту категории: условие относится к связанному документу Category и его slug. Синтаксис описан в REST Filters.

Для адреса статьи нужен точный фильтр slug, а не contains. Если искать подстроку form, библиотека могла бы вернуть и contact-form, и form-validation. Страница отдельной статьи должна получить один ожидаемый документ, поэтому сравнение $eq соответствует её назначению. Поиск каталога, наоборот, может использовать containsi, если заранее определена семантика такого поиска.

Нельзя построить безопасную серверную границу, просто передав весь query браузера в CMS. Посетитель сможет добавить draft, другой populate или большой limit. Наш сайт принимает только известный slug, locale и ограниченный page. Остальные параметры формирует сам. Фильтрация данных и валидация пользовательских параметров остаются разными уровнями, которые нельзя заменять друг другом.

URLSearchParams кодирует значения и имена параметров. Для небольшого фиксированного запроса его достаточно. Для сложного дерева фильтров официальные примеры часто используют qs. Мы не добавляем зависимость ради нескольких заранее известных ключей. При расширении запроса лучше сохранить его структуру в одном модуле и читать условия как объект, а не собирать непонятную длинную строку вручную.

Порядок и размер страницы

Порядок без явной сортировки не следует принимать за редакционное обещание. Сегодня записи выглядят расположенными по времени создания, завтра импорт или обновление изменит картину. Выберем title:asc, а при одинаковом title — documentId:asc. Второй ключ делает порядок определённым среди одинаковых заголовков; сам по себе алфавит не гарантирует уникальность названия.

Параметры pagination[page] и pagination[pageSize] задают страницу и количество документов. В нашем сайте pageSize равен шести. Это учебное решение для небольшого каталога, а не универсальный оптимальный размер. В конфигурации API defaultLimit равен десяти, maxLimit — тридцати. Ограничение клиента и ограничение CMS дополняют друг друга: клиент задаёт желаемую порцию, CMS задаёт верхнюю границу.

Можно использовать пагинацию по offset/start, но не смешивайте её со страницами в одном запросе. В этой серии ссылки представляют ?page=2, поэтому page-модель читается естественнее. Официальные варианты описаны в Sort and Pagination.

Полный site/cms.mjs содержит чтение published и отдельную функцию draft для будущего закрытого preview. Последняя пока не вызывается обычными страницами.

const base = new URL(process.env.CMS_URL || 'http://127.0.0.1:1337');

export async function readArticles({ locale = 'ru', page = 1, slug } = {}) {
  if (!['ru', 'en'].includes(locale)) throw new Error('Unsupported locale');
  const url = new URL('/api/articles', base);
  url.searchParams.set('status', 'published');
  url.searchParams.set('locale', locale);
  url.searchParams.set('sort[0]', 'title:asc');
  url.searchParams.set('sort[1]', 'documentId:asc');
  url.searchParams.set('pagination[page]', String(page));
  url.searchParams.set('pagination[pageSize]', '6');
  url.searchParams.set('populate[author][fields][0]', 'name');
  url.searchParams.set('populate[category][fields][0]', 'title');
  url.searchParams.set('populate[cover]', 'true');
  url.searchParams.set('populate[seo]', 'true');
  url.searchParams.set('populate[resources]', 'true');
  if (slug) url.searchParams.set('filters[slug][$eq]', slug);
  return request(url);
}

export async function readDraft(documentId, locale) {
  if (!/^[a-z0-9]{10,40}$/.test(documentId)) throw new Error('Invalid documentId');
  if (!['ru', 'en'].includes(locale)) throw new Error('Unsupported locale');
  const url = new URL(`/api/articles/${documentId}`, base);
  url.searchParams.set('status', 'draft');
  url.searchParams.set('locale', locale);
  return request(url);
}

async function request(url) {
  if (!process.env.CMS_TOKEN) throw new Error('Missing server CMS_TOKEN');
  const response = await fetch(url, {
    headers: { Authorization: `Bearer ${process.env.CMS_TOKEN}` },
    signal: AbortSignal.timeout(5000),
  });
  if (!response.ok) throw new Error(`CMS returned ${response.status}`);
  return response.json();
}

Обратите внимание на фиксированное значение status в readArticles. Оно не копируется из параметров посетителя. Внутри запроса явно названы populate для автора, категории, cover, seo и resources. Это делает контракт понятным даже тому, кто не знает текущий состав всей редакционной модели. Поля Name автора запрашиваются через fields, поэтому биография не нужна для получения подписи.

Пустая страница и изменяющаяся библиотека

Представим, что опубликованы три документа, а pageSize равно двум. Первая страница должна вернуть два документа и meta.pagination с total три, pageCount два. Вторая вернёт один. Третья может дать пустой список; сайт решит, считать ли это допустимым пустым каталогом или ошибочным номером. В нашем минимальном сервере большие и некорректные номера отсекаются до запроса, но отдельная политика для страницы сверх pageCount пока не добавлена.

Не рассчитывайте pageCount из количества объектов в текущем data. Последняя страница обычно меньше pageSize, но общее число документов сообщает meta. Если библиотека пуста, data пустой и интерфейс должен объяснить это состояние. Сетевую ошибку следует показать иначе: пользователь не должен думать, что редакция удалила все статьи, когда CMS временно недоступна.

При редактировании между двумя посещениями страниц offset-пагинация может показать документ повторно или пропустить его: новый заголовок меняет положение в сортировке. Для спокойной учебной библиотеки это приемлемая граница. Для активно меняющегося потока понадобятся другие решения, например стабильный cursor или snapshot выборки. Не заявляйте, что вторичный ключ полностью решает изменения набора; он решает только неоднозначность порядка при одинаковых значениях.

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

Не выдавайте поисковую строку за обычный slug без ограничения длины. В адресной странице набор символов небольшой и точный, а поиск принимает свободный текст и может требовать отдельной политики пустого значения. Запрос из одних пробелов должен получить понятный результат до обращения к CMS. Если позже появится форма поиска, сформулируйте её условия отдельно от readArticles: какие поля исследуются, как учитывается регистр, какой размер страницы и как показывается отсутствие совпадений. Это сохранит текущий простой контракт каталога и позволит объяснить новое действие без скрытого расширения входных параметров.

Для самостоятельного продолжения измените level «Формы обратной связи» на intermediate и сравните полный каталог с выборкой beginner. Должен измениться состав, а title/slug оставшихся записей сохранятся. Затем уменьшите pageSize и проследите meta. Так вы увидите три независимых механизма: фильтр отвечает, какие документы подходят; сортировка — в каком порядке; пагинация — какую часть этого порядка вернуть. Следующий урок покажет ещё одно независимое условие: какую редакционную версию читать.