⚡ perf(db): порядок инбокса по индексу, страницы без N+1

Пагинация была правильной, но опиралась на то, что не ложится в индекс: список
диалогов сортировался по агрегату max(messages.created_at) — GROUP BY по всей
ленте на каждый запрос, — а превью строки и бейдж непрочитанных спрашивались
по два запроса на диалог, тридцать раз на страницу, каждые четыре секунды.

- Conversation.last_message_at: денормализованное поле вместо агрегата, держит
  сигнал на создание сообщения (одно место вместо семи), миграция заполняет
  существующие диалоги;
- индексы: conv_inbox_order (организация → свежесть) и conv_message_window
  (диалог → время → id) — под окно инбокса и окно истории; conv_contact_recent
  под подзапросы списка контактов; триграммы для поиска по имени и телефону;
- превью и бейдж считаются на страницу целиком (last_messages_for,
  pending_counts_for), а не построчно;
- список сотрудников: своя сортировка групп в payload отменяла prefetch, а
  организация тянулась на каждую строку — обе связи взяты в выборке;
- test_query_budget: число запросов сравнивается на разном объёме данных, так
  что вернуть N+1 незаметно больше нельзя;
- журнал аудита переведён на общий usePagedResource — он был последним списком
  со своей копией отложенного поиска и защиты от гонок.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
AndreyandClaude Opus 5 committed 2026-09-09 02:12:34 +03:00
1 parent ed78617722
commit f3cbf1658c
12 files changed
+435 -90

No files matched your search

+12 -8
View File
@@ -1,8 +1,12 @@
from django.apps import AppConfig␍
␍
␍
class ConversationsConfig(AppConfig):␍
default_auto_field = "django.db.models.BigAutoField"␍
label = "conversations"␍
name = "chatballs.conversations"␍
verbose_name = "Conversations (contacts, dialogs, messages)"␍
from django.apps import AppConfig
class ConversationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
label = "conversations"
name = "chatballs.conversations"
verbose_name = "Conversations (contacts, dialogs, messages)"
def ready(self) -> None:
# Свежесть диалога поддерживает сигнал: сообщения создаются в семи местах.
from chatballs.conversations import signals # noqa: F401
@@ -0,0 +1,74 @@
"""Свежесть диалога — отдельным полем, порядок инбокса — по индексу.
Раньше список сортировался по агрегату max(messages.created_at): такой ключ не
ложится ни в индекс, ни в курсор окна, поэтому каждый запрос инбокса собирал
GROUP BY по всей ленте сообщений. Поле заполняется здесь и дальше держится
сигналом (chatballs.conversations.signals).
"""
import django.utils.timezone
from django.conf import settings
from django.contrib.postgres.operations import TrigramExtension
from django.db import migrations, models
CONTACT_TRGM_INDEXES = """
CREATE INDEX IF NOT EXISTS contact_name_trgm
ON conversations_contact USING gin ((UPPER(name)) gin_trgm_ops);
CREATE INDEX IF NOT EXISTS contact_phone_trgm
ON conversations_contact USING gin ((UPPER(phone)) gin_trgm_ops);
"""
DROP_CONTACT_TRGM_INDEXES = """
DROP INDEX IF EXISTS contact_name_trgm;
DROP INDEX IF EXISTS contact_phone_trgm;
"""
BACKFILL = """
UPDATE conversations_conversation AS c
SET last_message_at = COALESCE(
(SELECT MAX(m.created_at) FROM conversations_message AS m WHERE m.conversation_id = c.id),
c.last_activity_at
)
"""
class Migration(migrations.Migration):
dependencies = [
('channels', '0008_remove_channel_allow_checkout_actions_and_more'),
('conversations', '0019_alter_message_kind'),
('identity', '0032_remove_organization_tax_regime_and_more'),
('integrations', '0007_integration_feature_flags'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
# Поиск по контактам идёт подстрокой — без триграмм это перебор таблицы.
TrigramExtension(),
migrations.AddField(
model_name='conversation',
name='last_message_at',
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.RunSQL(sql=BACKFILL, reverse_sql=migrations.RunSQL.noop),
# Функциональный индекс с opclass заводится сырым SQL: Django 5.2
# рендерит его как «(UPPER(name) gin_trgm_ops)» — без скобок вокруг
# самого выражения, и Postgres такой синтаксис отвергает.
migrations.RunSQL(sql=CONTACT_TRGM_INDEXES, reverse_sql=DROP_CONTACT_TRGM_INDEXES),
migrations.AddIndex(
model_name='conversation',
index=models.Index(
fields=['organization', '-last_message_at', '-id'], name='conv_inbox_order'
),
),
migrations.AddIndex(
model_name='conversation',
index=models.Index(fields=['contact', '-last_activity_at'], name='conv_contact_recent'),
),
migrations.AddIndex(
model_name='message',
index=models.Index(
fields=['conversation', 'created_at', 'id'], name='conv_message_window'
),
),
]
+28 -1
View File
@@ -2,6 +2,7 @@ from django.conf import settings
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVector
from django.db import models
from django.utils import timezone
from chatballs.tenancy.models import TenantRelationModel
@@ -36,6 +37,11 @@ class Contact(models.Model):
)
created_at = models.DateTimeField(auto_now_add=True)
# Триграммные индексы для поиска подстрокой (имя, телефон) заведены в
# миграции 0020 сырым SQL: Django 5.2 рендерит функциональный индекс с
# opclass без внутренних скобок — «(UPPER(name) gin_trgm_ops)», и Postgres
# такой синтаксис не принимает.
def __str__(self) -> str:
return self.name or f"contact:{self.id}"
@@ -192,10 +198,27 @@ class Conversation(models.Model):
previous_conversation = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True, related_name="+")
created_at = models.DateTimeField(auto_now_add=True)
last_activity_at = models.DateTimeField(auto_now_add=True, db_index=True)
# Время последнего сообщения — порядок инбокса. Отдельно от last_activity_at,
# который двигают и служебные действия (перехват, возврат AI): по нему список
# сортировать нельзя. У диалога без сообщений — время создания, поэтому поле
# непустое и по нему работает и индекс, и курсор окна (api.pagination).
last_message_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ["-last_activity_at"]
indexes = [models.Index(fields=["channel", "lifecycle"])]
indexes = [
models.Index(fields=["channel", "lifecycle"]),
# Порядок инбокса: организация → свежесть. Раньше сортировка шла по
# агрегату max(messages.created_at) и в индекс не ложилась.
models.Index(
fields=["organization", "-last_message_at", "-id"], name="conv_inbox_order"
),
# Список контактов спрашивает по каждому контакту его свежий диалог
# и число открытых — подзапросами по этой паре.
models.Index(
fields=["contact", "-last_activity_at"], name="conv_contact_recent"
),
]
constraints = [
# Диалог всегда принадлежит контакту.
models.CheckConstraint(
@@ -304,6 +327,10 @@ class Message(TenantRelationModel):
SearchVector("text", config="russian"),
name="conv_message_text_fts",
),
# Окно истории идёт ровно по этому ключу: диалог → время → id.
models.Index(
fields=["conversation", "created_at", "id"], name="conv_message_window"
),
]
def __str__(self) -> str:
@@ -5,13 +5,11 @@ from django.db.models import (
DateTimeField,
F,
IntegerField,
Max,
Q,
QuerySet,
Value,
When,
)
from django.db.models.functions import Coalesce
from chatballs.api.pagination import SortKey
from chatballs.conversations.models import (
@@ -36,21 +34,20 @@ def conversations_for_context(context: TenantContext) -> QuerySet[Conversation]:
)
.prefetch_related("labels")
# Инбокс сортируется по времени последнего сообщения (а не по служебной
# активности вроде claim/takeover); fallback — last_activity_at для
# диалогов без сообщений. Coalesce вместо nulls_last: ключ сортировки
# обязан быть непустым, иначе курсор окна не сравнить (api.pagination).
.annotate(sort_at=Coalesce(Max("messages__created_at"), F("last_activity_at")))
.order_by("-sort_at", "-id")
# активности вроде claim/takeover). Поле денормализовано и держится
# сигналом: агрегат max(messages.created_at) не ложился ни в индекс, ни
# в курсор окна — на каждый запрос выходил GROUP BY по всей ленте.
.order_by("-last_message_at", "-id")
)
# Ключи сортировки инбокса. Порядок и правило сравнения курсора — одно и то же
# знание, поэтому оно живёт здесь, а не разъезжается по view.
ACTIVITY_KEYS = (SortKey("sort_at"), SortKey("id"))
ACTIVITY_KEYS = (SortKey("last_message_at"), SortKey("id"))
WAITING_KEYS = (
SortKey("_waiting_rank", descending=False),
SortKey("_wait_at", descending=False),
SortKey("sort_at"),
SortKey("last_message_at"),
SortKey("id"),
)
# Заглушка ключа ожидания для диалогов, которые человека не ждут: ключ окна
@@ -68,18 +65,18 @@ def order_conversations(
дольше всех ждущий выше», остальные — по убыванию активности.
"""
if sort != "waiting":
return queryset.order_by("-sort_at", "-id"), ACTIVITY_KEYS
return queryset.order_by("-last_message_at", "-id"), ACTIVITY_KEYS
waits = Q(lifecycle=LifecycleState.OPEN, control_mode=ControlMode.PAUSED)
ordered = queryset.annotate(
_waiting_rank=Case(
When(waits, then=Value(0)), default=Value(1), output_field=IntegerField()
),
_wait_at=Case(
When(waits, then=F("sort_at")),
When(waits, then=F("last_message_at")),
default=Value(_NOT_WAITING_AT),
output_field=DateTimeField(),
),
).order_by("_waiting_rank", "_wait_at", "-sort_at", "-id")
).order_by("_waiting_rank", "_wait_at", "-last_message_at", "-id")
return ordered, WAITING_KEYS
@@ -1,3 +1,5 @@
from django.db.models import Count, Max, Q
from chatballs.integrations.features import features_payload
from chatballs.conversations.models import (
ConnectionIdentity,
@@ -40,6 +42,54 @@ def message_payload(message: Message) -> dict[str, object]:
return payload
def last_messages_for(conversation_ids: list[int]) -> dict[int, Message]:
"""Последняя реплика каждого диалога страницы — одним запросом.
Превью строки списка раньше спрашивалось на каждый диалог: тридцать строк
инбокса стоили тридцати запросов, и обновление списка раз в четыре секунды
множило их на число операторов.
"""
if not conversation_ids:
return {}
rows = (
Message.objects.filter(conversation_id__in=conversation_ids)
.exclude(author_type=MessageAuthor.SYSTEM)
.select_related("author_user")
.order_by("conversation_id", "-created_at", "-id")
.distinct("conversation_id")
)
return {message.conversation_id: message for message in rows}
def pending_counts_for(conversation_ids: list[int], read_map: dict[int, int]) -> dict[int, int]:
"""Бейджи непрочитанных для страницы — двумя запросами вместо строки-на-строку.
Считается хвост клиентских сообщений: те, что пришли после последнего
ответа AI или оператора и которых просматривающий ещё не открывал.
"""
if not conversation_ids:
return {}
answered = dict(
Message.objects.filter(conversation_id__in=conversation_ids)
.exclude(author_type=MessageAuthor.CONTACT)
.values("conversation_id")
.annotate(last_id=Max("id"))
.values_list("conversation_id", "last_id")
)
tail = Q()
for conversation_id in conversation_ids:
threshold = max(read_map.get(conversation_id, 0), answered.get(conversation_id, 0))
tail |= Q(conversation_id=conversation_id, id__gt=threshold)
counts = (
Message.objects.filter(author_type=MessageAuthor.CONTACT)
.filter(tail)
.values("conversation_id")
.annotate(total=Count("id"))
.values_list("conversation_id", "total")
)
return dict(counts)
def _last_message(conversation: Conversation) -> Message | None:
# Превью строки списка — последняя реплика клиента/AI/сотрудника; системные
# события («AI передал диалог») в превью не показываются (дизайн-базлайн v2, B).
@@ -123,6 +173,8 @@ def conversation_payload(
detailed: bool = False,
last_read_id: int = 0,
viewer_id: int | None = None,
last_message: Message | None = None,
pending_count: int | None = None,
) -> dict[str, object]:
"""Карточка диалога.
@@ -130,7 +182,9 @@ def conversation_payload(
собственным окном (`/messages/`), иначе открытие диалога с тысячей реплик
тянуло бы их все, да ещё и на каждом обновлении карточки.
"""
last = None if detailed else _last_message(conversation)
# Списку превью и бейдж считает страница целиком (last_messages_for,
# pending_counts_for); поштучный расчёт остаётся для одиночных ответов.
last = None if detailed else (last_message or _last_message(conversation))
channel = conversation.channel
payload = {
"id": conversation.id,
@@ -212,5 +266,7 @@ def conversation_payload(
).exists()
else:
payload["lastMessage"] = message_payload(last) if last else None
payload["pendingCount"] = _pending_count(conversation, last_read_id)
payload["pendingCount"] = (
pending_count if pending_count is not None else _pending_count(conversation, last_read_id)
)
return payload
@@ -0,0 +1,27 @@
"""Сигналы домена диалогов.
Единственная задача — держать `Conversation.last_message_at` в согласии с
лентой. Сообщения создаются в семи местах (приём из каналов, ответ оператора,
голосовые, файлы, события звонков, демо-данные), поэтому обновление живёт в
сигнале: иначе достаточно одного забытого места, чтобы диалог перестал
подниматься в инбоксе.
"""
from django.db.models.signals import post_save
from django.dispatch import receiver
from chatballs.conversations.models import Conversation, Message
@receiver(post_save, sender=Message, dispatch_uid="conversations.touch_last_message_at")
def touch_last_message_at(sender, instance: Message, created: bool, **kwargs) -> None:
"""Свежесть диалога двигается только вперёд.
Условие `__lt` защищает от задним числом импортированной переписки: старое
сообщение не должно опускать диалог в инбоксе.
"""
if not created or instance.created_at is None:
return
Conversation.objects.filter(
id=instance.conversation_id, last_message_at__lt=instance.created_at
).update(last_message_at=instance.created_at)
@@ -0,0 +1,136 @@
"""Число запросов на страницу не зависит от размера набора.
Это и есть защита от возврата исходной беды: список или лента, которые
разрастаются вместе с базой, начинаются с одного лишнего запроса на строку.
Числа здесь не зафиксированы намеренно — сравниваются два прогона на разном
объёме данных, поэтому тест не ломается от посторонних изменений и ловит ровно
N+1.
"""
from django.db import connection
from django.test import TestCase
from django.test.utils import CaptureQueriesContext
from chatballs.channels.models import Channel
from chatballs.conversations.models import (
Contact,
Conversation,
LifecycleState,
Message,
MessageAuthor,
)
from chatballs.identity.bootstrap import bootstrap_owner
from chatballs.identity.models import (
EmployeeRole,
HumanUser,
Organization,
OrganizationMembership,
)
from chatballs.integrations.models import (
Integration,
IntegrationKind,
IntegrationProvider,
)
from chatballs.testing import TenantAPIClient as APIClient
class QueryBudgetTests(TestCase):
def setUp(self) -> None:
bootstrap_owner(email="owner@example.com", password="temporary-password")
self.organization = Organization.objects.get(slug="demo")
self.channel = Channel.objects.create(
organization=self.organization, code="line", name="Линия"
)
self.connection_row = Integration.objects.create(
organization=self.organization,
kind=IntegrationKind.MESSENGER,
provider=IntegrationProvider.TELEGRAM,
name="bot",
channel=self.channel,
)
self.client = APIClient()
self.client.login(username="owner@example.com", password="temporary-password")
def _conversation(self, name: str, messages: int = 1) -> Conversation:
contact = Contact.objects.create(organization=self.organization, name=name)
conversation = Conversation.objects.create(
organization=self.organization,
channel=self.channel,
connection=self.connection_row,
contact=contact,
lifecycle=LifecycleState.OPEN,
)
for index in range(messages):
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.CONTACT,
text=f"{name} — реплика {index}",
)
return conversation
def _employee(self, index: int) -> None:
user = HumanUser.objects.create_user(
email=f"member{index:03d}@example.com",
password="Password-123",
full_name=f"Сотрудник {index:03d}",
)
OrganizationMembership.objects.create(
user=user,
organization=self.organization,
role=EmployeeRole.EMPLOYEE,
position_title="Оператор",
)
def _queries(self, url: str) -> int:
with CaptureQueriesContext(connection) as captured:
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
return len(captured)
def _assert_flat(self, url: str, grow) -> None:
"""Сколько бы строк ни добавилось, запросов должно остаться столько же."""
# Первый запрос после входа тянет за собой сессию и контекст аренды —
# прогреваем, иначе сравнивались бы разные вещи.
self._queries(url)
before = self._queries(url)
grow()
after = self._queries(url)
self.assertEqual(after, before, f"{url}: запросов стало {after} вместо {before}")
def test_inbox_window_does_not_grow_with_dialogs(self) -> None:
for index in range(3):
self._conversation(f"Клиент {index:02d}")
self._assert_flat(
"/api/v1/conversations/?limit=30",
lambda: [self._conversation(f"Ещё клиент {index:02d}") for index in range(12)],
)
def test_history_window_does_not_grow_with_messages(self) -> None:
conversation = self._conversation("Иван", messages=5)
self._assert_flat(
f"/api/v1/conversations/{conversation.id}/messages/?limit=50",
lambda: [
Message.objects.create(
conversation=conversation,
author_type=MessageAuthor.CONTACT,
text=f"Ещё реплика {index}",
)
for index in range(30)
],
)
def test_clients_page_does_not_grow_with_contacts(self) -> None:
for index in range(3):
self._conversation(f"Контакт {index:02d}")
self._assert_flat(
"/api/v1/conversations/clients/",
lambda: [self._conversation(f"Новый контакт {index:02d}") for index in range(12)],
)
def test_employees_page_does_not_grow_with_staff(self) -> None:
for index in range(3):
self._employee(index)
self._assert_flat(
"/api/v1/employees/",
lambda: [self._employee(index) for index in range(10, 22)],
)
+18 -6
View File
@@ -22,12 +22,12 @@ from chatballs.conversations.selectors import (
order_conversations,
visible_conversations_for,
)
from chatballs.conversations.serializers import conversation_payload, message_payload
# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить
# меньше, больше — только до потолка api.pagination.
LIST_WINDOW_SIZE = 30
MESSAGE_WINDOW_SIZE = 50
from chatballs.conversations.serializers import (
conversation_payload,
last_messages_for,
message_payload,
pending_counts_for,
)
from chatballs.conversations.services import (
ClaimError,
claim_conversation,
@@ -43,6 +43,11 @@ from chatballs.identity.group_models import EmployeeGroup
from chatballs.identity.models import OrganizationMembership
from chatballs.identity.policy import ResourceScope, authorize, can_administer_access
# Окно инбокса и окно истории: размеры продуктовые, клиент может запросить
# меньше, больше — только до потолка api.pagination.
LIST_WINDOW_SIZE = 30
MESSAGE_WINDOW_SIZE = 50
class ConversationListView(ConversationViewBase):
def get(self, request: Request) -> Response:
@@ -113,6 +118,11 @@ class ConversationListView(ConversationViewBase):
user=request.user, conversation__in=page.items
).values_list("conversation_id", "last_read_message_id")
)
# Превью и бейдж — на всю страницу разом: построчно это давало по два
# запроса на диалог при обновлении списка раз в четыре секунды.
conversation_ids = [conversation.id for conversation in page.items]
previews = last_messages_for(conversation_ids)
pending = pending_counts_for(conversation_ids, read_map)
return Response(
window_payload(
page,
@@ -120,6 +130,8 @@ class ConversationListView(ConversationViewBase):
c,
last_read_id=read_map.get(c.id, 0),
viewer_id=request.user.id,
last_message=previews.get(c.id),
pending_count=pending.get(c.id, 0),
),
# Счётчик над списком показывает весь охват с учётом фильтров,
# а не число уже загруженных строк.
@@ -6,8 +6,9 @@
from __future__ import annotations
from django.db.models import Q, QuerySet
from django.db.models import Prefetch, Q, QuerySet
from chatballs.identity.group_models import EmployeeGroupMember
from chatballs.identity.models import OrganizationMembership
ROLE_ANY = "all"
@@ -15,8 +16,17 @@ ROLE_ANY = "all"
def employees_for(organization_id: int, params) -> QuerySet[OrganizationMembership]:
employees = (
OrganizationMembership.objects.select_related("user")
.prefetch_related("group_links__group")
# organization нужен для адреса аватара, группы — с их порядком: обе
# связи берутся здесь, иначе payload спрашивал бы их на каждую строку.
OrganizationMembership.objects.select_related("user", "organization")
.prefetch_related(
Prefetch(
"group_links",
queryset=EmployeeGroupMember.objects.select_related("group").order_by(
"group__name"
),
)
)
.filter(organization_id=organization_id)
)
role = params.get("role")
@@ -11,9 +11,11 @@ def employee_payload(
*,
include_detail: bool = False,
) -> dict[str, object]:
# Порядок групп задаёт выборка (employees_for): своя сортировка здесь
# отменяла бы prefetch и давала запрос на каждого сотрудника в списке.
groups = [
{"id": link.group_id, "name": link.group.name}
for link in profile.group_links.select_related("group").order_by("group__name")
for link in profile.group_links.all()
]
payload: dict[str, object] = {
"id": profile.user_id,
@@ -1,61 +1,53 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { ApiError } from "../../api/client";
import { useDebounced } from "../../shared/useDebounced";
import { usePagedResource } from "../../shared/usePagedResource";
import { loadAudit } from "./api";
import { EMPTY_AUDIT_QUERY, type AuditPayload, type AuditQuery } from "./model";
// Журнал аудита: фильтры и страница живут на сервере — журнал растёт без
// предела, и отдать его целиком нельзя. Поиск отложен, чтобы не слать запрос на
// каждую букву; смена любого фильтра возвращает на первую страницу — иначе
// человек остаётся на странице 7 отфильтрованного списка из двух событий.
const SEARCH_DELAY_MS = 300;
// предела, и отдать его целиком нельзя. Страницей, отложенным поиском и
// защитой от гонок занимается общий usePagedResource: журнал был единственным
// списком со своей копией этой механики.
export function useAudit() {
const [query, setQueryState] = useState<AuditQuery>(EMPTY_AUDIT_QUERY);
const [payload, setPayload] = useState<AuditPayload | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState(EMPTY_AUDIT_QUERY.q);
useEffect(() => {
const timer = window.setTimeout(() => setDebouncedSearch(query.q), SEARCH_DELAY_MS);
return () => window.clearTimeout(timer);
}, [query.q]);
// Ответы медленного запроса не должны затирать более свежий: считаем поколения.
const generation = useRef(0);
const effective: AuditQuery = { ...query, q: debouncedSearch };
const { period, category, actor, result, page } = effective;
const reload = useCallback(async () => {
const current = ++generation.current;
setLoading(true);
setError("");
try {
const next = await loadAudit({ q: debouncedSearch, period, category, actor, result, page });
if (current === generation.current) setPayload(next);
} catch (loadError) {
if (current !== generation.current) return;
setError(loadError instanceof ApiError ? loadError.message : "Не удалось загрузить журнал");
} finally {
if (current === generation.current) setLoading(false);
}
}, [debouncedSearch, period, category, actor, result, page]);
useEffect(() => { void reload(); }, [reload]);
const settledSearch = useDebounced(query.q);
const filters = useMemo(
() => ({
q: settledSearch,
period: query.period,
category: query.category,
actor: query.actor,
result: query.result,
}),
[query.actor, query.category, query.period, query.result, settledSearch],
);
const load = useCallback(
(page: number) => loadAudit({ ...filters, page }),
[filters],
);
const journal = usePagedResource<AuditPayload>(load, filters, "Не удалось загрузить журнал");
const setQuery = useCallback((patch: Partial<AuditQuery>) => {
setQueryState((prev) => ({
...prev,
...patch,
// Страницу сбрасывает всё, кроме явной смены страницы.
page: patch.page ?? 1,
}));
}, []);
// Страницу двигает подвал списка, фильтры — состояние запроса; смена
// фильтра сама возвращает на первую страницу (usePagedResource).
if (patch.page !== undefined) {
journal.setPage(patch.page);
return;
}
setQueryState((prev) => ({ ...prev, ...patch }));
}, [journal]);
const reset = useCallback(() => setQueryState(EMPTY_AUDIT_QUERY), []);
return { query, setQuery, reset, payload, loading, error, reload };
return {
query: { ...query, page: journal.page },
setQuery,
reset,
payload: journal.payload,
loading: journal.loading,
error: journal.errorText,
reload: journal.reload,
};
}
+18 -10
View File
@@ -15,7 +15,10 @@ export type PagedPayload<T> = {
const EMPTY: PagedPayload<never> = { items: [], page: 1, pageSize: 0, total: 0, pageCount: 1 };
export type PagedResource<T> = {
export type PagedResource<T, P> = {
/** Ответ целиком: у некоторых списков в нём есть и свои блоки — например
* справочники фильтров журнала аудита. */
payload: P | null;
items: T[];
page: number;
pageCount: number;
@@ -27,12 +30,14 @@ export type PagedResource<T> = {
reload: () => Promise<void>;
};
export function usePagedResource<T>(
load: (page: number) => Promise<PagedPayload<T>>,
// Тип записи выводится из самого ответа: у части списков в нём есть и свои
// блоки (например справочники фильтров журнала аудита), поэтому параметр — ответ.
export function usePagedResource<P extends PagedPayload<unknown>>(
load: (page: number) => Promise<P>,
filters: unknown,
errorMessage = "Не удалось загрузить список",
): PagedResource<T> {
const [payload, setPayload] = useState<PagedPayload<T>>(EMPTY as PagedPayload<T>);
): PagedResource<P["items"][number], P> {
const [payload, setPayload] = useState<P | null>(null);
const [loading, setLoading] = useState(true);
const [errorText, setErrorText] = useState("");
// Загрузчик пересоздаётся на каждый рендер — держим его в ref, чтобы эффект
@@ -76,14 +81,17 @@ export function usePagedResource<T>(
void reload();
}, [key, reload]);
// Пока ответа нет, список ведёт себя как пустая первая страница.
const current = payload ?? (EMPTY as PagedPayload<P["items"][number]>);
return {
items: payload.items,
payload,
items: current.items,
// Сервер возвращает существующую страницу: если записи удалили из-под
// открытой страницы, он отдаст последнюю, и подвал покажет именно её.
page: payload.page,
pageCount: payload.pageCount,
pageSize: payload.pageSize,
total: payload.total,
page: current.page,
pageCount: current.pageCount,
pageSize: current.pageSize,
total: current.total,
loading,
errorText,
setPage,