Перейти к основному содержимому

Курсорная пагинация

Все list-эндпоинты MineFlow используют курсорную пагинацию (не offset). Ответ всегда имеет форму { items, nextCursor }: серверный предел страницы — до 200 элементов, а nextCursor указывает, откуда тянуть следующую страницу. nextCursor === null означает, что страниц больше нет.

Этот рецепт показывает, как читать списки страница-за-страницей и как стянуть весь набор сразу. За полным API хуков — гайд client-react, за низкоуровневым ядром — client-core.

Формат страницы

import type { CursorPage } from '@mineflow/client-core';

interface CursorPage<T> {
items: T[];
nextCursor: string | null; // null = последняя страница
}

CursorPage<T> экспортируется из @mineflow/client-core и реэкспортируется из @mineflow/client-react. Все list-хуки (useAssets, usePersonnel, useShiftReports, useNotifications, …) и сырые GET-запросы возвращают именно эту форму.

к сведению
baseUrl и /api/v1

Org-scope и multi-tenancy полностью на сервере (по JWT). В курсоре и query-параметрах organization_id не передаётся. Подробнее про конфиг клиента — в гайде client-react.

Списочные хуки отдают nextCursor

Концептуальные list-хуки client-react возвращают стандартный UseQueryResult, в data которого лежит { items, nextCursor }:

import { useAssets } from '@mineflow/client-react';

function AssetsList() {
const { data, isLoading, error } = useAssets({ status: 'operational', limit: 50 });

if (isLoading) return <Spinner />;
if (error) return <ErrorView error={error} />;

return (
<>
{data?.items.map((a) => (
<AssetRow key={a.id} asset={a} />
))}
{/* есть ли следующая страница */}
{data?.nextCursor && <LoadMore cursor={data.nextCursor} />}
</>
);
}

data.nextCursor — это всё, что нужно, чтобы решить, показывать ли «Загрузить ещё». Само значение курсора непрозрачно: не парси его, просто передавай обратно в следующий запрос.

Ручная подгрузка «Загрузить ещё»

Самый прямой способ — держать текущий курсор в стейте и запрашивать следующую страницу через дженерик-хук useApiQuery, склеивая items в приложении. Курсор передаётся в query-параметре cursor:

import { useState } from 'react';
import { useApiQuery, queryKeys } from '@mineflow/client-react';
import { unwrap } from '@mineflow/client-core';

function PersonnelPicker({ positionId }: { positionId: string }) {
const [cursor, setCursor] = useState<string | null>(null);
const [rows, setRows] = useState<Personnel[]>([]);

const page = useApiQuery(
[...queryKeys.personnel.all, 'paged', positionId, cursor],
async (c) =>
unwrap(
await c.GET('/api/v1/hr/personnel', {
params: { query: { positionId, limit: 50, ...(cursor ? { cursor } : {}) } },
}),
),
);

// накапливаем items по мере прихода страниц
useEffect(() => {
if (page.data) setRows((prev) => [...prev, ...page.data.items]);
}, [page.data]);

return (
<>
{rows.map((p) => (
<PersonRow key={p.id} person={p} />
))}
{page.data?.nextCursor && (
<button onClick={() => setCursor(page.data!.nextCursor)}>Загрузить ещё</button>
)}
</>
);
}
подсказка
Первый запрос — без cursor

Первую страницу запрашивай вообще без поля cursor (либо с cursor: undefined). Не передавай пустую строку или null — отдай query-объект, в котором ключа cursor нет.

queryKeys — единый источник ключей кэша TanStack Query. Подмешивай в ключ cursor (и фильтры), чтобы каждая страница кэшировалась отдельно. Подробнее про ключи — в гайде client-react.

Стянуть все страницы: collectAllPages

Когда UI обязан видеть полный набор (экспорт, агрегат, расчёт по всем строкам), используй collectAllPages из @mineflow/client-core. Он сам гоняет курсор до конца и возвращает плоский массив:

import { createMineflowClient, collectAllPages, unwrap } from '@mineflow/client-core';

const client = createMineflowClient({
/* … */
});

const allAssets = await collectAllPages((cursor) =>
unwrap(
client.GET('/api/v1/eam/assets', {
params: { query: { limit: 200, ...(cursor ? { cursor } : {}) } },
}),
),
);
// allAssets — один массив со всеми активами организации

fetchPage принимает текущий курсор (null на первой итерации) и должен вернуть CursorPage<T>. collectAllPages останавливается, как только nextCursor станет null.

warning
Предохранитель maxPages

collectAllPages принимает второй аргумент { maxPages } (по умолчанию 1000). Это защита от битого nextCursor, который зациклил бы запросы и подвесил UI. Не отключай предохранитель; подними лимит явно, только если реально ожидаешь больше 1000 страниц:

await collectAllPages(fetchPage, { maxPages: 2000 });
Запрашивай страницы по 200

В collectAllPages ставь limit: 200 (максимум сервера) — меньше round-trip'ов на полный обход. Дробные limit имеют смысл только для постраничного UI, где важна latency первой страницы.

В React: useApiQueryAll и *All-хуки

Тот же «собрать всё» под TanStack Query — без ручного склеивания и со встроенным кэшем — даёт хук useApiQueryAll из client-react. Он оборачивает collectAllPages и возвращает UseQueryResult<T[]>data — плоский массив, а не CursorPage):

import { useApiQueryAll, queryKeys } from '@mineflow/client-react';
import { unwrap } from '@mineflow/client-core';

function ObjectPersonnelPicker({ objectId }: { objectId: string }) {
const { data, isLoading } = useApiQueryAll(
[...queryKeys.personnel.all, 'all', objectId],
async (client, cursor) => {
const page = unwrap(
await client.GET('/api/v1/hr/personnel', {
params: { query: { limit: 200, ...(cursor ? { cursor } : {}) } },
}),
);
return { items: page.items, nextCursor: page.nextCursor };
},
);

if (isLoading) return <Spinner />;
return <Picker options={data ?? []} />; // data: Personnel[] | undefined
}

useApiQueryAll принимает третий аргумент { enabled?, maxPages? } — тот же предохранитель, что и в core.

Для самых частых сценариев готовые обёртки уже есть — useAssetsAll и usePersonnelAll. Они принимают только фильтры (без cursor/limit — их хук гонит сам) и используют отдельный ключ кэша, чтобы не пересекаться с одностраничными useAssets/usePersonnel:

import { useAssetsAll, usePersonnelAll } from '@mineflow/client-react';

const { data: assets } = useAssetsAll({ status: 'operational' }); // все активы участка
const { data: people } = usePersonnelAll({ positionId }); // все по должности
Когда НЕ тянуть всё

*All-обёртки нужны там, где список заведомо помещается в память (пикеры персонала/техники участка, lookup'ы). Для крупных или растущих коллекций (рапорты, уведомления, аудит) делай постраничную подгрузку через list-хук + nextCursor — иначе один «собрать всё» прогонит сотни страниц на каждый рендер-инвалидацию.

Справочники (refs-kit)

Долгоживущие lookup'ы (useProductionObjects, usePositions, useAssetClasses) — это отдельные refs-хуки, и пагинацию они обычно скрывают сами. Если тебе нужен picker по справочнику — бери их, а не строй обход курсора руками. Список доступных refs-хуков — в гайде client-react.

Что дальше