fix(ui): ограничить каталог пользователей и добавить поиск
This commit is contained in:
1 parent
e0f83bdf42
commit
5d9a6f3757
8 files changed
+118
-14
No files matched your search
@@ -1,7 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react"
|
||||
import { useCallback, useEffect, useRef, useState, useMemo } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { DataPageToolbar } from "@/components/data-page-toolbar"
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { UsersDataGrid } from "@/components/data-grids/users-data-grid"
|
||||
import { KpiStatGrid } from "@/components/reui-kit/kpi-stat-grid"
|
||||
@@ -76,6 +77,11 @@ function UsersContent() {
|
||||
const canReadServers = can("mm:servers:read")
|
||||
|
||||
const [users, setUsers] = useState<AppUser[]>(() => isLive ? [] : INIT_USERS)
|
||||
const [userSearch, setUserSearch] = useState("")
|
||||
const visibleUsers = useMemo(() => {
|
||||
const query = userSearch.trim().toLocaleLowerCase("ru-RU")
|
||||
return query ? users.filter(user => [user.name, user.login, user.email].some(value => value.toLocaleLowerCase("ru-RU").includes(query))) : users
|
||||
}, [users, userSearch])
|
||||
const [serverOptions, setServerOptions] = useState<UserServerOption[]>(() => isLive ? [] : toServerOptionsFromMock())
|
||||
const [loading, setLoading] = useState(isLive && canRead)
|
||||
const [usersReadAt, setUsersReadAt] = useState<number | null>(null)
|
||||
@@ -299,8 +305,11 @@ function UsersContent() {
|
||||
|
||||
{/* Preview: https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/docs/components/base/data-grid · https://reui.io/docs/components/base/frame */}
|
||||
{canRead && (usersKnown || loading) && <DataPageCard>
|
||||
<DataPageToolbar search={userSearch} onSearchChange={setUserSearch} searchLabel="Поиск по имени, логину или email" searchPlaceholder="Поиск по имени, логину или email…" countLabel={usersKnown ? `${visibleUsers.length} из ${users.length}` : "Данные пока неизвестны"} />
|
||||
<UsersDataGrid
|
||||
users={users}
|
||||
users={visibleUsers}
|
||||
emptyTitle={userSearch.trim() ? "Нет совпадений" : "Нет пользователей"}
|
||||
emptyDescription={userSearch.trim() ? "Измените поиск" : "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"}
|
||||
serversCount={serversKnown ? serverOptions.length : null}
|
||||
allSectionsCount={ALL_SECTIONS.length}
|
||||
isLoading={loading && !usersKnown}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
import { DataGridContainer, DataGridScrollArea, DataGridTable, dataGridFeatures, type DataGridFeatures } from "@/components/reui/data-grid"
|
||||
|
||||
import { useMemo } from "react"
|
||||
import { useMemo, useEffect } from "react"
|
||||
import {
|
||||
type ColumnDef,
|
||||
useTable,
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
DATA_GRID_CELL_PAD,
|
||||
DATA_GRID_CELL_PAD_FIRST,
|
||||
DATA_GRID_CELL_PAD_LAST,
|
||||
DEFAULT_DATA_GRID_LAYOUT,
|
||||
} from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { DataGridSortHeader } from "@/components/data-grids/shared/data-grid-sort-header"
|
||||
import { UsersExpandedDetail } from "@/components/data-grids/users-expanded-detail"
|
||||
@@ -33,7 +34,7 @@ function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||
return (
|
||||
<div aria-hidden="true"
|
||||
className={cn(
|
||||
"size-8 rounded-full flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
|
||||
"size-8 rounded-full hidden sm:flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
|
||||
!active && "opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -48,6 +49,8 @@ interface UsersDataGridProps {
|
||||
allSectionsCount?: number
|
||||
isLoading?: boolean
|
||||
actionsDisabled?: boolean
|
||||
emptyTitle?: string
|
||||
emptyDescription?: string
|
||||
onEdit: (user: AppUser) => void
|
||||
onDelete: (user: AppUser) => void
|
||||
}
|
||||
@@ -58,6 +61,8 @@ function UsersDataGrid({
|
||||
allSectionsCount = ALL_SECTIONS.length,
|
||||
isLoading,
|
||||
actionsDisabled,
|
||||
emptyTitle = "Нет пользователей",
|
||||
emptyDescription = "Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик",
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: UsersDataGridProps) {
|
||||
@@ -66,6 +71,7 @@ function UsersDataGrid({
|
||||
{
|
||||
id: "name",
|
||||
accessorKey: "name",
|
||||
size: 180,
|
||||
header: ({ column }) => (
|
||||
<DataGridSortHeader column={column} title="Пользователь" className="ml-1" />
|
||||
),
|
||||
@@ -83,8 +89,8 @@ function UsersDataGrid({
|
||||
</Button>
|
||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium truncate">{u.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground truncate">{u.email || u.login}</p>
|
||||
<p className="text-sm font-medium truncate" title={u.name}>{u.name}</p>
|
||||
<p className="text-xs font-mono text-muted-foreground truncate" title={u.email || u.login}>{u.email || u.login}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -171,6 +177,7 @@ function UsersDataGrid({
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
size: 106,
|
||||
enableSorting: false,
|
||||
header: () => <span className="sr-only">Действия</span>,
|
||||
cell: ({ row }) => {
|
||||
@@ -220,19 +227,28 @@ function UsersDataGrid({
|
||||
|
||||
const table = useTable({
|
||||
features: dataGridFeatures,
|
||||
manualPagination: true,
|
||||
manualPagination: false,
|
||||
autoResetPageIndex: false,
|
||||
autoResetExpanded: false,
|
||||
initialState: { pagination: { pageIndex: 0, pageSize: 50 }, columnPinning: { start: ["name"], end: [] } },
|
||||
data: users,
|
||||
columns,
|
||||
getRowId: (row) => row.id,
|
||||
getRowCanExpand: () => true,
|
||||
})
|
||||
|
||||
const { pageIndex, pageSize } = table.state.pagination
|
||||
useEffect(() => {
|
||||
const lastPage = Math.max(0, Math.ceil(users.length / pageSize) - 1)
|
||||
if (pageIndex > lastPage) table.setPageIndex(lastPage)
|
||||
}, [users.length, pageSize, pageIndex, table])
|
||||
|
||||
if (!isLoading && users.length === 0) {
|
||||
return (
|
||||
<EmptyState
|
||||
icon={<UsersIcon aria-hidden="true" className="size-4" />}
|
||||
title="Нет пользователей"
|
||||
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
|
||||
title={emptyTitle}
|
||||
description={emptyDescription}
|
||||
className="border-0 py-12"
|
||||
/>
|
||||
)
|
||||
@@ -242,10 +258,16 @@ function UsersDataGrid({
|
||||
<DataGridShell
|
||||
table={table}
|
||||
recordCount={users.length}
|
||||
pagination={users.length > 50 || pageSize !== 50}
|
||||
tableLayout={{ ...DEFAULT_DATA_GRID_LAYOUT, columnsPinnable: true }}
|
||||
isLoading={isLoading}
|
||||
loadingMode="skeleton"
|
||||
onRowClick={(row) => table.getRow(row.id).toggleExpanded()}
|
||||
/>
|
||||
>
|
||||
<DataGridContainer border={false} className="w-full rounded-none border-0">
|
||||
<DataGridScrollArea orientation="horizontal"><DataGridTable /></DataGridScrollArea>
|
||||
</DataGridContainer>
|
||||
</DataGridShell>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -38,12 +38,11 @@
|
||||
| [route-optimizer-wan-matrix-data-grid.tsx](../components/data-grids/route-optimizer-wan-matrix-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
|
||||
| [subusers-data-grid.tsx](../components/data-grids/subusers-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
|
||||
| [traffic-flows-data-grid.tsx](../components/data-grids/traffic-flows-data-grid.tsx) | FlowAnalyticsPanel получает conversationsList с backend cap50; API page/limit здесь нет |
|
||||
| [users-data-grid.tsx](../components/data-grids/users-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
|
||||
| [vxlan-data-grid.tsx](../components/data-grids/vxlan-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
|
||||
| [wireguard-data-grid.tsx](../components/data-grids/wireguard-data-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
|
||||
| [wireguard-peers-grid.tsx](../components/data-grids/wireguard-peers-grid.tsx) | Потребитель API, контекст/детали и fixture1000 |
|
||||
|
||||
Осталось23 специализированных grids; ни один из оставшегося перечня не принят этим документом на больших данных. CompactDataGrid дополнительно проверен на повторном получении100 active probes: страница сохраняется; сокращение100→1 ограничивает индекс. Speed probes и uptime resources приняты в блоках12.3.7–12.3.9 и исключены из оставшегося перечня. Следующие большие профили: probes-schedule, затем сетевые/admin grids с учётом границ API. Общий журнал: [ux-ui-progress.md](ux-ui-progress.md).
|
||||
Осталось22 специализированных grids; ни один из оставшегося перечня не принят этим документом на больших данных. CompactDataGrid дополнительно проверен на повторном получении100 active probes: страница сохраняется; сокращение100→1 ограничивает индекс. Speed probes и uptime resources приняты в блоках12.3.7–12.3.9 и исключены из оставшегося перечня. Следующие большие профили: probes-schedule, затем сетевые/admin grids с учётом границ API. Общий журнал: [ux-ui-progress.md](ux-ui-progress.md).
|
||||
|
||||
12.3.6:1000 speed probes baseline —1000 rendered rows/23590 DOM nodes/3741ms dev ready; профиль collector-probes4 cases и ESLint прошли. Далее12.3.7 ограничение DOM speed grid; этот baseline не означает принятую производительность.
|
||||
|
||||
@@ -70,3 +69,5 @@ Statistics pivot в backend/src/services/statistics-aggregate.ts огранич
|
||||
12.3.16:certificates1000/329034 JSON bytes —1000→50 rows/29666→2145 DOM nodes,1098ms final dev ready. Whole-catalog search/URL/reload/sort/page20/SAN/refresh/1000→1/320px/axe проверены. Осталось23 manualPagination:true файла; закрепление идентификатора строки при горизонтальной прокрутке — следующий UX блок.
|
||||
|
||||
12.3.17: закреплённое имя и значения полностью видимы на320px после корректировки размеров; pivot action при focus не перекрывается.29 browser/axe/typecheck/build прошли. Certificate final50 rows/2145 nodes; pinning ограничивает положение колонок, не число загружаемых DTO.
|
||||
|
||||
12.3.18:users1000/263680 JSON bytes —1000→50 rows/37505→2393 DOM nodes/921ms dev ready. Whole-catalog name/login/email search, global sort/page20/refresh/details/error recovery/1000→1/правильный пользователь в форме/320px/axe проверены;32 browser/type/lint/build. Поиск не сохраняется в URL; полные DTO остаются в памяти.
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
|
||||
## Актуальная точка продолжения — 2026-10-04
|
||||
|
||||
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. Следующий блок12.3.18:users1000, затем backups/network grids и отдельная достоверность history;12.4/12.5 ещё не завершены. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
|
||||
Этап 11Б завершён в границах локальной приёмки (11Б.7); внешний выпуск не проверен. 12.1 принят локально: доступность оболочки/клавиатура/health в описанных сценариях. 12.2.1 принят локально: strict BGP catalog/partial/nullable counters. 12.2.2 принят локально: strict traffic DTO и агрегация без выдуманных точек. 12.2.3 принят локально: observations для server traffic от backend до dashboard. 12.2.4 принят локально: strict servers/probes и unknown server status. 12.2.5 принят локально: реальные ping samples/unknown/ось RTT/Recharts. 12.2.6 принят локально: strict GRE/WireGuard/VXLAN и device failures. 12.2.7 принят локально: strict Internet path snapshot/fallback. 12.2.8 принят локально: подтверждение полного backend сбора. 12.2.9 принят локально: метрики участков и наблюдаемый WAN без эвристической подмены. 12.3.1 принят локально:1000 серверов/штатная пагинация. 12.3.2 принят локально: пагинация CompactDataGrid/1000 строк статистики. 12.3.3 принят локально: порционный DOM очереди проблем. 12.3.4 принят локально: большой BGP каталог/страница при обновлении. Production build и123 профильных browser приняты локально на3978976. 12.2.10 принят локально: обязательные RouterOS поля. 12.3.5 принят локально: страница CompactDataGrid сохраняется при опросе,89 browser и повторный production build прошли. 12.3.6 сохранён: baseline1000 speed probes (ограничение DOM пока не выполнено). 12.3.7 принят локально:50 строк speed-проб/сохранение страницы/глобальная сортировка/1000→1; каталог1001 устройства остаётся в DOM. 12.3.8 принят локально:50 строк ресурсов, URL/поиск/сохранение страницы/1000→1; исправлена pagination оболочки с children, production build и8 больших browser прошли. 12.3.9 принят локально:порционное раскрытие1000 предупреждений ресурсов/клавиатура/axe; исходная ошибка ESLint speedDraft подтверждена. 12.3.10 принят локально:недоступные интерфейсы очищаются в обработчиках,54 browser/полный ESLint/production build прошли. 12.3.11 принят локально:ServerTileRail50 устройств/URL/поиск/клавиатура/две площадки/mobile Sheet/axe обе темы;47 browser и production build прошли. 12.3.12 принят локально:BGP ручная страница при выбранной URL сессии/refresh/reload,16 browser/build прошли. 12.4.1 записан:main1091 сценарий/1088 passed/3 устаревших fixtures; оба исправленных файла6 passed. 12.4.2 принят локально:context205/24 файла и unit28 прошли. 12.3.13 принят локально:wide pivot51×16/ScrollArea/axe/24+2 browser/builds. 12.3.14 принят локально:history200/50 DOM/54+1 browser/build. 12.3.15 принят локально:live расписание1000/page20 error cache/15 browser. 12.3.16 принят локально:certificates1000/50 DOM/refresh деталей/19+3 browser/build. 12.3.17 принят локально:pinning идентификатора/видимость значений/29 browser/build. 12.3.18 принят локально:users1000/поиск всего списка/детали и ошибка refresh/32 browser/build. Следующий блок12.3.19:backups1000, затем network grids и отдельная достоверность history;12.4/12.5 ещё не завершены. См. docs/ux-ui-stage-12.md. Внешние ограничения 11Б сохраняются.
|
||||
|
||||
Реальные pg_dump/pg_restore, PG rollback и остановка UDP worker требуют внешней приёмки. Датированные продолжения ниже — история; точный следующий шаг также указан в начале [журнала](ux-ui-progress.md).
|
||||
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## 12.3.18 — каталог пользователей1000 и поиск по всему списку (2026-10-04)
|
||||
|
||||
Strict appUserReadSchema fixture1000/263680 JSON bytes. Baseline1000 строк/37505 DOM nodes/2174ms dev ready; после client pagination50:50 строк/2393 nodes/921ms. Поиск по имени, логину и email добавлен через общую DataPageToolbar, фильтрует весь каталог. KPI и данные формы остаются полными; поиск локальный, без URL persistence. Stable ID/autoResetPageIndex=false/autoResetExpanded=false и clamp сохраняют страницу и детали при refresh/503 и корректно сокращают1000→1.
|
||||
|
||||
Штатные ReUI ScrollArea и pinning имени180/actions106 обеспечивают полностью видимые действия на320px; декоративный avatar скрыт на телефоне. Проверены page20, клавиатура/детали, ошибка и recovery, глобальные поиск и сортировка, правильный пользователь в форме после sort, возврат focus, отсутствие POST, reflow и tagged axe.32 browser users-data/layout/operations/large-users прошли; TypeScript и ESLint без ошибок. Мобильный снимок просмотрен. Production build прошёл:34 страницы и contracts.
|
||||
|
||||
Следующий блок12.3.19: backups1000, проверка фактического источника/пагинации/поиска/refresh/действий/320px. Осталось22 manualPagination:true файла, включая ограниченные и demo источники. Достоверность speed history и финальная матрица12.4 остаются; внешняя приёмка12.5 отдельно. Коммит локальный, без push.
|
||||
|
||||
## 12.3.17 — идентификатор строки при горизонтальной прокрутке (2026-10-04)
|
||||
|
||||
Имя сертификата и измерение pivot закреплены штатным TanStack columnPinning/start и ReUI columnsPinnable. CompactDataGrid получает optional pinnedColumnId и размеры колонок; default consumers сохраняют прежний режим. Мобильные размеры проверены по реальным bounding boxes: имя160/status120 для сертификатов, измерение140/метрики и итог110 для pivot. Ранние180/150 ширины перекрывали часть значения на320px; финальные размеры устраняют это. Дублирующий status icon в имени сертификата скрыт только на телефоне, полное имя остаётся в title/details.
|
||||
|
||||
@@ -281,3 +281,11 @@ Fixture strict per-device certificatesStateSchema,1000 сертификатов
|
||||
Scoped scroll-padding-inline-start на viewport обеспечивает видимость кнопки ячейки при keyboard focus возле закреплённого измерения. Проверены полностью видимые статус/итог/действие вне pinned области, границы имени, Enter→двойной срез,320/1440/axe/sort/search/refresh/expanded/1000→1.29 browser в7 файлах прошли после финальной правки. Typecheck/ESLint и production frontend/contracts34 страницы прошли. Снимки сертификатов/pivot/history обновлены и просмотрены. Повторяющаяся UNKNOWN open historical PNG в certificate-layout устранена использованием штатного info.outputPath: текущие layout артефакты отдельно от сохранённых исторических снимков, assertions не ослаблялись.
|
||||
|
||||
Следующий блок12.3.18:users1000 через strict API, пагинация/весь поиск/refresh деталей/ошибка и восстановление/правильный пользователь в действии/320px. Затем backups/network grids; достоверность history из12.3.14 и финальная матрица12.4 остаются. Полный main/context после delta ещё не повторён;12.5 внешняя приёмка отдельно. Локальный коммит без push.
|
||||
|
||||
## 12.3.18 — каталог пользователей1000 и поиск по всему списку (2026-10-04)
|
||||
|
||||
Strict appUserReadSchema fixture1000/263680 JSON bytes. Baseline1000 строк/37505 DOM nodes/2174ms dev ready; после client pagination50:50 строк/2393 nodes/921ms. Поиск по имени, логину и email добавлен через общую DataPageToolbar, фильтрует весь каталог. KPI и данные формы остаются полными; поиск локальный, без URL persistence. Stable ID/autoResetPageIndex=false/autoResetExpanded=false и clamp сохраняют страницу и детали при refresh/503 и корректно сокращают1000→1.
|
||||
|
||||
Штатные ReUI ScrollArea и pinning имени180/actions106 обеспечивают полностью видимые действия на320px; декоративный avatar скрыт на телефоне. Проверены page20, клавиатура/детали, ошибка и recovery, глобальные поиск и сортировка, правильный пользователь в форме после sort, возврат focus, отсутствие POST, reflow и tagged axe.32 browser users-data/layout/operations/large-users прошли; TypeScript и ESLint без ошибок. Мобильный снимок просмотрен. Production build прошёл:34 страницы и contracts.
|
||||
|
||||
Следующий блок12.3.19: backups1000, проверка фактического источника/пагинации/поиска/refresh/действий/320px. Осталось22 manualPagination:true файла, включая ограниченные и demo источники. Достоверность speed history и финальная матрица12.4 остаются; внешняя приёмка12.5 отдельно. Коммит локальный, без push.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 49 KiB |
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { appUserReadSchema } from "../../packages/contracts/src/users"
|
||||
import { user, server, catalogResponse } from "./fixtures/users"
|
||||
import axe from "axe-core"
|
||||
import { mkdirSync } from "node:fs"
|
||||
|
||||
test("Users1000: DOM, весь поиск, детали/ошибка refresh и правильное действие", async ({ page }, info) => {
|
||||
test.setTimeout(90000)
|
||||
let users = Array.from({ length: 1000 }, (_, i) => appUserReadSchema.parse({ ...user, id: "u-" + (i + 1), name: "Клиент " + String(i + 1).padStart(4, "0"), login: "user" + (i + 1), email: "user" + (i + 1) + "@example.test" }))
|
||||
let calls = 0, failed = false; const writes: string[] = []
|
||||
await page.route("**/health", r => r.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", r => {
|
||||
const path = new URL(r.request().url()).pathname
|
||||
if (r.request().method() !== "GET") writes.push(path)
|
||||
if (path === "/api/users") { calls++; if (failed) return r.fulfill({ status: 503, json: { error: "Каталог временно недоступен" } }) }
|
||||
return catalogResponse(r, { json: path === "/api/auth/config" ? { required: false } : path === "/api/users" ? { users } : path === "/api/servers" ? [server] : { interfaces: [] } })
|
||||
})
|
||||
const start = Date.now(); await page.goto("/users")
|
||||
const rows = page.locator("tr[data-row-id]")
|
||||
await expect(page.getByText("Клиент 0001", { exact: true })).toBeVisible()
|
||||
const sample = { fixtureRows: 1000, fixtureBytes: Buffer.byteLength(JSON.stringify(users)), renderedRows: await rows.count(), domNodes: await page.locator("*").count(), readyMs: Date.now() - start }
|
||||
console.log(JSON.stringify(sample)); await info.attach("large-users-metrics", { body: JSON.stringify(sample), contentType: "application/json" })
|
||||
expect(sample.renderedRows).toBe(50); expect(sample.domNodes).toBeLessThan(5000)
|
||||
for (let i = 0; i < 3; i++) await page.getByRole("button", { name: "…", exact: true }).last().click()
|
||||
await page.getByRole("button", { name: "Перейти на страницу 20", exact: true }).click()
|
||||
const detail = page.getByRole("button", { name: "Доступ и привязки пользователя Клиент 1000", exact: true })
|
||||
await detail.focus(); await page.keyboard.press("Enter"); await expect(detail).toHaveAttribute("aria-expanded", "true")
|
||||
const refresh = page.getByRole("button", { name: "Обновить пользователей", exact: true })
|
||||
const before = calls; await refresh.click(); await expect.poll(() => calls).toBeGreaterThan(before); await expect(refresh).toBeEnabled()
|
||||
await expect(page.getByText("951–1000 из 1000", { exact: true })).toBeVisible(); await expect(detail).toHaveAttribute("aria-expanded", "true")
|
||||
failed = true; await refresh.click(); await expect(page.getByRole("alert", { name: "Пользователи", exact: true })).toBeVisible()
|
||||
await expect(rows).toHaveCount(50); await expect(detail).toHaveAttribute("aria-expanded", "true")
|
||||
failed = false; await refresh.click(); await expect(page.getByRole("alert", { name: "Пользователи", exact: true })).toHaveCount(0)
|
||||
const search = page.getByRole("textbox", { name: "Поиск по имени, логину или email", exact: true })
|
||||
for (const q of ["Клиент 1000", "user1000@example.test"]) { await search.fill(q); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "u-1000") }
|
||||
await search.fill("нет совпадений"); await expect(page.getByText("Нет совпадений", { exact: true })).toBeVisible()
|
||||
await search.fill(""); await expect(rows).toHaveCount(50)
|
||||
const sort = page.getByRole("table").getByRole("button", { name: "Пользователь", exact: true }); await sort.click(); await sort.click()
|
||||
await expect(rows.first()).toHaveAttribute("data-row-id", "u-1000")
|
||||
await page.setViewportSize({ width: 320, height: 1000 })
|
||||
const scroll = page.locator('[data-slot="data-grid"] [data-slot="scroll-area-viewport"]'); await expect(scroll).toHaveCount(1)
|
||||
await scroll.evaluate(el => { el.scrollLeft = el.scrollWidth })
|
||||
const edit = rows.first().getByRole("button", { name: "Редактировать пользователя Клиент 1000", exact: true })
|
||||
expect(await edit.evaluate(el => { const value = el.getBoundingClientRect(), viewport = el.closest('[data-slot="scroll-area-viewport"]')!.getBoundingClientRect(), pinned = el.closest("tr")!.querySelector("td")!.getBoundingClientRect(); return value.left >= pinned.right - 1 && value.right <= viewport.right + 1 })).toBe(true)
|
||||
await edit.focus(); await page.keyboard.press("Enter")
|
||||
await expect(page.getByRole("textbox", { name: "Полное имя", exact: true })).toHaveValue("Клиент 1000")
|
||||
await page.getByRole("dialog").getByRole("button", { name: "Отмена", exact: true }).click(); await expect(edit).toBeFocused()
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true)
|
||||
await page.addScriptTag({ content: axe.source })
|
||||
const audit = await page.evaluate(async () => (window as unknown as { axe: typeof axe }).axe.run(document.querySelector('[data-slot="data-grid"]')!, { runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] } })); expect(audit.violations).toEqual([])
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" }); mkdirSync("docs/ux-ui/screens", { recursive: true })
|
||||
await page.getByRole("table").getByRole("button", { name: "Пользователь", exact: true }).scrollIntoViewIfNeeded(); await page.screenshot({ path: "docs/ux-ui/screens/large-users-320.png", animations: "disabled" })
|
||||
await page.getByRole("button", { name: "Следующая страница", exact: true }).click(); users = [users[999]]; const previous = calls; await refresh.click()
|
||||
await expect.poll(() => calls).toBeGreaterThan(previous); await expect(rows).toHaveCount(1); await expect(rows.first()).toHaveAttribute("data-row-id", "u-1000")
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
Reference in new issue
Block a user