fix(ui): сохранять фильтры серверов в адресе страницы

This commit is contained in:
Denozordec committed 2026-09-21 09:50:47 +07:00
1 parent b64e6834a9
commit dfb993e1a3
7 files changed
+256 -10

No files matched your search

+6 -5
View File
@@ -11,7 +11,7 @@ import { PageHeader } from "@/components/page-header"
import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit"
import { DataPageToolbar } from "@/components/data-page-toolbar" import { DataPageToolbar } from "@/components/data-page-toolbar"
import { ServersDataGrid } from "@/components/data-grids/servers-data-grid" import { ServersDataGrid } from "@/components/data-grids/servers-data-grid"
import { createFilterQuery } from "@/components/reui/filters/filters-query" import { useServerUrlState } from "@/hooks/use-server-url-state"
import { ServerFilters } from "@/components/data-grids/server-filters" import { ServerFilters } from "@/components/data-grids/server-filters"
import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query" import { applyFilterQuery } from "@/lib/data-filters/apply-filter-query"
import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields" import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields"
@@ -231,9 +231,7 @@ function ServersPageContent() {
const [serverList, setServerList] = useState<Server[]>([]) const [serverList, setServerList] = useState<Server[]>([])
const [listState, setListState] = useState<"loading" | "ready" | "error">("loading") const [listState, setListState] = useState<"loading" | "ready" | "error">("loading")
const [search, setSearch] = useState("") const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState()
const [typeFilter, setTypeFilter] = useState<TypeFilter>("all")
const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery())
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [sheetMode, setSheetMode] = useState<SheetMode>("add") const [sheetMode, setSheetMode] = useState<SheetMode>("add")
const [editingId, setEditingId] = useState<string | null>(null) const [editingId, setEditingId] = useState<string | null>(null)
@@ -561,6 +559,7 @@ function ServersPageContent() {
const selectedServer = serverList.find(server => server.id === selectedServerId) const selectedServer = serverList.find(server => server.id === selectedServerId)
const filtered = useMemo(() => { const filtered = useMemo(() => {
if (urlError) return []
const base = serverList.filter(sv => { const base = serverList.filter(sv => {
if (selectedServerId !== null && sv.id !== selectedServerId) return false if (selectedServerId !== null && sv.id !== selectedServerId) return false
if (typeFilter !== "all" && sv.type !== typeFilter) return false if (typeFilter !== "all" && sv.type !== typeFilter) return false
@@ -569,7 +568,7 @@ function ServersPageContent() {
return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q) return sv.name.toLowerCase().includes(q) || sv.host.includes(q) || sv.site.toLowerCase().includes(q)
}) })
return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS) return applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS)
}, [serverList, search, typeFilter, advancedFilters, selectedServerId]) }, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError])
const counts = useMemo(() => ({ const counts = useMemo(() => ({
all: serverList.length, all: serverList.length,
@@ -668,6 +667,8 @@ function ServersPageContent() {
/> />
{/* Table */} {/* Table */}
{urlError && <Alert variant="warning" role="alert"><AlertTitle>Фильтры требуют внимания</AlertTitle><AlertDescription>{urlError}</AlertDescription></Alert>}
{hasFilters && <div><Button variant="outline" size="sm" onClick={resetFilters}>Сбросить фильтры списка</Button></div>}
<DataPageCard> <DataPageCard>
<DataPageToolbar <DataPageToolbar
segmented={{ segmented={{
+11 -1
View File
@@ -21,7 +21,7 @@
| 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже | | 4 | Остальные таблицы, удаление старых API, общая регрессия | Завершён 2026-09-20; границы приёмки ниже |
| 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже | | 5 | Состояния данных, свежесть, частичные сбои, гонки polling | Завершён 2026-09-20 для дашборда; границы проверок ниже |
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже | | 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Подготовлена схема и инвентаризация; реализация 7.1 ожидает следующего блока | | 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | В работе: блок 7.1 URL-фильтров серверов завершён; далее 7.2 |
| 8 | Трафик и статистика | Не начат | | 8 | Трафик и статистика | Не начат |
| 9 | VPN, Firewall и Filters | Не начат | | 9 | VPN, Firewall и Filters | Не начат |
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат | | 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
@@ -477,6 +477,16 @@ Production build успешен: контракты, TypeScript, 34 страни
Проверка подготовки — сверка путей, типов, обработчиков и правил backend по исходникам, git diff --check. Тесты приложения повторно не запускались: изменения только документационные. Сохранить локальным коммитом без push. **Следующий шаг: блок 7.1 по ux-ui-stage-7.md — codec URL серверов с unit-тестами, затем подключение к странице и профильные E2E.** Этап 7 не считать реализованным. Проверка подготовки — сверка путей, типов, обработчиков и правил backend по исходникам, git diff --check. Тесты приложения повторно не запускались: изменения только документационные. Сохранить локальным коммитом без push. **Следующий шаг: блок 7.1 по ux-ui-stage-7.md — codec URL серверов с unit-тестами, затем подключение к странице и профильные E2E.** Этап 7 не считать реализованным.
## Этап 7 — блок 7.1: URL-фильтры серверов (2026-09-21)
Реализован codec `lib/data-filters/server-url-state.ts`, hook `hooks/use-server-url-state.ts` и подключение к ServersPageContent. Параметры q/type/filters восстанавливают поиск, тип и дерево Filters; server остаётся точным контекстом из этапа 6. JSON имеет версию v=1, сохраняет AND/OR, вложенность и negated. Проверяются поля/значения текущих SERVER_FILTER_FIELDS, операторы select, уникальные ID, размер 8 KiB, глубина 4, число узлов 50. Неизвестный тип, повторные параметры или повреждённые фильтры показывают предупреждение и не расширяют выдачу до всех серверов.
Черновик условия не сериализуется. Сброс q/type/filters сохраняет server и чужие query-параметры. Дискретные изменения используют native pushState; поиск — replaceState без debounce. Это уточнение плана обосновано текущим Next App Router: useSearchParams обновляется без навигационного запроса, быстрый ввод не создаёт записи на каждый символ и не имеет отложенного обновления, затирающего соседние параметры. Тест проверяет неизменное число GET списка при вводе. Back/Forward очищают локальный черновик и восстанавливают URL-состояние; формы и API операций не менялись.
Проверки: 4 unit-теста codec passed; 21 прежний сценарий servers passed; 6 новых URL E2E passed после уточнения селектора alert (в странице также есть Next route announcer). Отдельно повторён сценарий с мобильной проверкой на 390 px, скриншот просмотрен. Проверены прямой URL, Back/Forward/reload, OR/negated, завершение условия, быстрый ввод, отсутствие запросов записи, сброс с server/keep, повреждённый/слишком большой JSON. TypeScript, ESLint всех изменённых TS-файлов и production build (34 страницы) успешны. HTTP-моки, реальные устройства не проверены.
Квота: использовано 1% на старте, 30% перед заключительной сборкой (недельной 66%). Сохранить локальным коммитом без push. **Следующий шаг — 7.2:** мобильные строки Ping-проб в uptime/page.tsx, доступность имени/цели/состояния и меню без обрезки на 320/390 px с сохранением действий, выбора и раскрытия; затем права по блоку 7.3. Этап 7 в целом остаётся открытым.
## Карта миграции (исходная инвентаризация этапа 0) ## Карта миграции (исходная инвентаризация этапа 0)
Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация. Исходная связка на этапе 0: @tanstack/react-table ^8.21.3, @tanstack/react-virtual ^3.14.4, base-nova. По итогам этапа 4 используется @tanstack/react-table 9.2.4 с useTable/dataGridFeatures и дерево FilterQuery вместо старого filters/onChange. Список ниже сохранён как историческая инвентаризация.
+8 -4
View File
@@ -1,6 +1,6 @@
# Этап 7: серверы и мониторинг # Этап 7: серверы и мониторинг
Подготовка 2026-09-20 по текущему коду после этапа 6. Реализация ещё не начата. Основа: ReUI PRO, base-nova, Frame; переиспользовать установленные Filters/DataGrid, ServerFilters, PageHeader и Sheet. Не менять vendored-компоненты и правила сетевых операций. Подготовка 2026-09-20 по текущему коду после этапа 6. Блок 7.1 реализован 2026-09-21; остальные блоки ожидают выполнения. Основа: ReUI PRO, base-nova, Frame; переиспользовать установленные Filters/DataGrid, ServerFilters, PageHeader и Sheet. Не менять vendored-компоненты и правила сетевых операций.
## Инвентаризация ## Инвентаризация
@@ -37,12 +37,12 @@
## Блок 7.1 — URL серверов ## Блок 7.1 — URL серверов
Предлагаемый контракт реализации: Реализованный контракт:
| Параметр | Значение | Правило | | Параметр | Значение | Правило |
| --- | --- | --- | | --- | --- | --- |
| `server` | Точный ID из этапа 6 | Сохранять при изменении фильтров; отсутствие объекта не подменять другим | | `server` | Точный ID из этапа 6 | Сохранять при изменении фильтров; отсутствие объекта не подменять другим |
| `q` | Строка поиска | Пустое значение удалять; ввод обновлять через replace с задержкой, без записи каждого символа в историю | | `q` | Строка поиска | Пустое значение удалять; ввод обновлять сразу через native replaceState, без новых записей истории и повторных запросов API |
| `type` | jump-host / exit-node / home-router | «Все» — отсутствие параметра; дискретный выбор добавляет запись истории | | `type` | jump-host / exit-node / home-router | «Все» — отсутствие параметра; дискретный выбор добавляет запись истории |
| `filters` | JSON `{v:1, query: FilterQuery}` | URLSearchParams кодирует значение; сохранить AND/OR и структуру групп | | `filters` | JSON `{v:1, query: FilterQuery}` | URLSearchParams кодирует значение; сохранить AND/OR и структуру групп |
@@ -61,4 +61,8 @@ URL — источник сохранённого состояния. Back/Forwa
3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие. 3. **7.4 — формы, детали и полный контекст мониторинга.** Восстановление вкладки/фильтров из URL, понятные единицы и подписи, возврат к списку после действия. Не дублировать сохранённые исправления этапа 1; проверить загрузку, отмену, 400/403/500, timeout и повторное нажатие.
4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении. 4. **7.5 — приёмка этапа.** Сценарий «проблема → сервер/проверка → действие → возврат» с сохранением контекста; профильные E2E, права в HTTP-моках, TypeScript, lint и build. Реальные JWT/RouterOS отдельно в тестовом окружении.
Следующий исполняемый шаг: codec URL серверов и его тесты, затем подключение к ServersPageContent. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии. Следующий исполняемый шаг: блок 7.2 — мобильные строки Ping-проб. Codec URL серверов, hook и подключение к ServersPageContent выполнены. Полную регрессию всей системы здесь не повторять без изменений общей основы или найденной регрессии.
## Решение по синхронизации URL (7.1)
Поиск использует native replaceState без debounce: установленный Next App Router синхронизирует useSearchParams, не запрашивая серверные данные. Браузерный тест подтвердил быстрый посимвольный ввод и неизменное количество GET /api/servers. Это устраняет отложенную запись, способную затереть последующий выбор type/filters. Дискретные изменения используют pushState, читая актуальный window.location.search. Черновик Filters привязан к сохранённому filters и сбрасывается при popstate. Неверные type/filters блокируют выдачу до исправления/сброса; server и чужие параметры сохраняются.
+47
View File
@@ -0,0 +1,47 @@
"use client"
import { useEffect, useMemo, useState } from "react"
import { useSearchParams } from "next/navigation"
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { encodeServerQuery, patchServerUrl, readServerUrl, validateServerQuery, type ServerTypeFilter } from "@/lib/data-filters/server-url-state"
export function useServerUrlState() {
const params = useSearchParams()
const raw = params.toString()
const state = useMemo(() => readServerUrl(new URLSearchParams(raw)), [raw])
const filterKey = params.get("filters")
const [draft, setDraft] = useState<{ key: string | null; query: FilterQuery; error: string | null } | null>(null)
useEffect(() => {
const reset = () => setDraft(null)
window.addEventListener("popstate", reset)
return () => window.removeEventListener("popstate", reset)
}, [])
function write(changes: Record<string, string | null>, replace = false) {
const href = patchServerUrl(new URLSearchParams(window.location.search), changes)
if (href === window.location.pathname + window.location.search) return
if (replace) window.history.replaceState(null, "", href)
else window.history.pushState(null, "", href)
}
function setFilters(query: FilterQuery) {
if (validateServerQuery(query) === "draft") { setDraft({ key: filterKey, query, error: null }); return }
try {
const encoded = encodeServerQuery(query)
setDraft(null)
write({ filters: encoded })
} catch (error) {
setDraft({ key: filterKey, query, error: error instanceof Error ? error.message : "Некорректный фильтр." })
}
}
const currentDraft = draft?.key === filterKey ? draft : null
return {
search: state.search,
typeFilter: state.type,
filters: currentDraft?.query ?? state.query,
error: state.error ?? currentDraft?.error ?? null,
hasFilters: params.has("q") || params.has("type") || params.has("filters") || currentDraft !== null,
setSearch: (value: string) => write({ q: value }, true),
setTypeFilter: (value: ServerTypeFilter) => write({ type: value === "all" ? null : value }),
setFilters,
reset: () => { setDraft(null); write({ q: null, type: null, filters: null }) },
}
}
+37
View File
@@ -0,0 +1,37 @@
import assert from "node:assert/strict"
import { test } from "node:test"
import { emptyServerQuery, encodeServerQuery, patchServerUrl, readServerUrl, validateServerQuery } from "./server-url-state"
import type { FilterQuery } from "../../components/reui/filters/filters-types"
const query = (): FilterQuery => ({ ...emptyServerQuery(), combinator: "or", rules: [
{ id: "a", type: "rule", path: ["country"], operator: "is_any_of", value: ["RU", "DE"], negated: true },
{ id: "nested", type: "group", combinator: "and", rules: [{ id: "b", type: "rule", path: ["status"], operator: "is", value: "offline" }] },
] })
test("URL roundtrip preserves groups, negation, context and unrelated keys", () => {
const href = patchServerUrl(new URLSearchParams("server=2&keep=1"), { q: "Тест & /", type: "exit-node", filters: encodeServerQuery(query()) })
const params = new URL(href, "http://test").searchParams
assert.deepEqual(readServerUrl(params), { search: "Тест & /", type: "exit-node", query: query(), error: null })
assert.equal(patchServerUrl(params, { q: null, type: null, filters: null }), "/servers?server=2&keep=1")
})
test("draft conditions never serialize as a completed filter", () => {
const draft = { ...emptyServerQuery(), rules: [{ id: "draft", type: "rule" as const, path: ["status"], operator: "is", value: undefined }] }
assert.equal(validateServerQuery(draft), "draft")
assert.throws(() => encodeServerQuery(draft))
assert.ok(readServerUrl(new URLSearchParams({ filters: JSON.stringify({ v: 1, query: draft }) })).error)
})
test("invalid URL variants are rejected instead of widening a selection", () => {
for (const filters of ["{", "null", JSON.stringify({ v: 2, query: query() }), " ".repeat(8193), JSON.stringify({ v: 1, query: { ...query(), rules: [{ id: "x", type: "rule", path: ["password"], operator: "is", value: "RU" }] } })]) {
assert.ok(readServerUrl(new URLSearchParams({ filters })).error)
}
assert.ok(readServerUrl(new URLSearchParams("type=unexpected")).error)
assert.ok(readServerUrl(new URLSearchParams("type=exit-node&type=jump-host")).error)
})
test("limits, IDs, operator arity and values are validated", () => {
const rule = { id: "r", type: "rule", path: ["country"], operator: "is", value: "RU" }
for (const rules of [[rule, rule], [{ ...rule, operator: "unknown" }], [{ ...rule, value: "XX" }], [{ ...rule, operator: "is_any_of" }], [{ ...rule, operator: "empty" }], Array.from({ length: 51 }, (_, i) => ({ ...rule, id: String(i) }))]) {
assert.equal(validateServerQuery({ ...emptyServerQuery(), rules }), "invalid")
}
let nested: FilterQuery = emptyServerQuery()
for (let i = 0; i < 6; i++) nested = { id: "group" + i, type: "group", combinator: "and", rules: [nested] }
assert.equal(validateServerQuery(nested), "invalid")
assert.equal(encodeServerQuery(emptyServerQuery()), null)
})
+68
View File
@@ -0,0 +1,68 @@
import type { FilterQuery } from "@/components/reui/filters/filters-types"
import { SERVER_FILTER_FIELDS } from "./server-filter-fields"
export type ServerTypeFilter = "all" | "jump-host" | "exit-node" | "home-router"
export const emptyServerQuery = (): FilterQuery => ({ id: "root", type: "group", combinator: "and", rules: [] })
const types = ["jump-host", "exit-node", "home-router"]
const MAX_BYTES = 8192
const options = new Map(SERVER_FILTER_FIELDS.map(field => [field.id, new Set((field.options ?? []).map(option => option.value))]))
const record = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value)
export function validateServerQuery(value: unknown): "valid" | "draft" | "invalid" {
const ids = new Set<string>()
let count = 0
let draft = false
function visit(node: unknown, depth: number): boolean {
if (!record(node) || depth > 4 || ++count > 50 || typeof node.id !== "string" || !node.id || ids.has(node.id)) return false
ids.add(node.id)
if (node.type === "group") return (node.combinator === "and" || node.combinator === "or") && Array.isArray(node.rules) && node.rules.every(child => visit(child, depth + 1))
if (node.type !== "rule" || !Array.isArray(node.path) || node.path.length !== 1 || typeof node.path[0] !== "string") return false
const allowed = options.get(node.path[0])
if (!allowed || (node.negated !== undefined && typeof node.negated !== "boolean")) return false
if (node.operator === "" || node.operator === undefined) { draft = true; return true }
if (node.operator === "empty" || node.operator === "not_empty") return node.value === undefined
if (!["is", "is_not", "is_any_of", "is_none_of"].includes(String(node.operator))) return false
if (node.value === undefined || node.value === "" || (Array.isArray(node.value) && node.value.length === 0)) { draft = true; return true }
if (node.operator === "is" || node.operator === "is_not") return typeof node.value === "string" && allowed.has(node.value)
return Array.isArray(node.value) && node.value.every(item => typeof item === "string" && allowed.has(item))
}
if (!record(value) || value.type !== "group" || !visit(value, 0)) return "invalid"
return draft ? "draft" : "valid"
}
export function encodeServerQuery(query: FilterQuery): string | null {
if (validateServerQuery(query) !== "valid") throw new Error("Завершите условия фильтра и проверьте их значения.")
if (query.rules.length === 0) return null
const encoded = JSON.stringify({ v: 1, query })
if (new TextEncoder().encode(encoded).length > MAX_BYTES) throw new Error("Слишком большой набор фильтров для ссылки.")
return encoded
}
export function readServerUrl(params: URLSearchParams) {
const search = params.get("q") ?? ""
const rawType = params.get("type")
const type: ServerTypeFilter = types.includes(rawType ?? "") ? rawType as ServerTypeFilter : "all"
let error: string | null = rawType !== null && !types.includes(rawType) ? "В ссылке указан неизвестный тип сервера." : null
let query = emptyServerQuery()
const raw = params.get("filters")
if (["q", "type", "filters"].some(key => params.getAll(key).length > 1)) error = "В ссылке повторяются параметры списка."
if (raw !== null) {
try {
if (new TextEncoder().encode(raw).length > MAX_BYTES) throw new Error()
const parsed: unknown = JSON.parse(raw)
if (!record(parsed) || parsed.v !== 1 || validateServerQuery(parsed.query) !== "valid") throw new Error()
query = parsed.query as FilterQuery
} catch { error = "Не удалось прочитать фильтры из ссылки. Сбросьте фильтры и задайте их заново." }
}
return { search, type, query, error }
}
/** Update only owned keys; retain object context and parameters from other features. */
export function patchServerUrl(params: URLSearchParams, changes: Record<string, string | null>) {
const next = new URLSearchParams(params)
for (const [key, value] of Object.entries(changes)) {
if (value === null || value === "") next.delete(key)
else next.set(key, value)
}
return "/servers" + (next.size ? "?" + next.toString() : "")
}
+79
View File
@@ -362,3 +362,82 @@ test("изменение подключения сбрасывает резул
await page.getByPlaceholder("api-user", { exact: true }).fill("changed-user") await page.getByPlaceholder("api-user", { exact: true }).fill("changed-user")
await expect(page.getByText("Проверено тестовым API", { exact: true })).toHaveCount(0) await expect(page.getByText("Проверено тестовым API", { exact: true })).toHaveCount(0)
}) })
test("URL серверов: поиск, тип, история, reload и сохранение контекста", async ({ page }) => {
const writes: string[] = []
await setup(page, async (route) => { if (!["GET", "HEAD"].includes(route.request().method())) writes.push(route.request().method()); return false }, 2, "/servers?server=2&keep=1")
const search = page.getByPlaceholder("Поиск по имени, хосту…")
await search.fill("Проверка 2")
await page.getByRole("button", { name: /JumpHost/ }).click()
await expect(page).toHaveURL(/type=jump-host/)
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await page.goBack()
await expect(search).toHaveValue("Проверка 2")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await page.goForward()
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await page.reload()
await expect(search).toHaveValue("Проверка 2")
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await page.setViewportSize({ width: 390, height: 844 })
const reset = page.getByRole("button", { name: "Сбросить фильтры списка", exact: true })
await reset.scrollIntoViewIfNeeded()
const bounds = await reset.boundingBox()
expect(bounds!.x + bounds!.width).toBeLessThanOrEqual(390)
await page.screenshot({ path: "test-results/server-url-mobile.png", animations: "disabled" })
await page.getByRole("button", { name: "Сбросить фильтры списка", exact: true }).click()
await expect(page).toHaveURL(/\/servers\?server=2&keep=1$/)
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
expect(writes).toEqual([])
})
test("URL серверов: дерево OR и negated восстанавливает строки и контролы", async ({ page }) => {
const query = { id: "root", type: "group", combinator: "or", rules: [
{ id: "country", type: "rule", path: ["country"], operator: "is", value: "DE", negated: true },
{ id: "status", type: "rule", path: ["status"], operator: "is", value: "online" },
] }
await setup(page, undefined, 2, "/servers?" + new URLSearchParams({ filters: JSON.stringify({ v: 1, query }) }))
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.locator("tr[data-row-id]")).toContainText("Проверка 1")
await page.reload()
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
await expect(page.getByRole("button", { name: "DE", exact: true })).toBeVisible()
await page.getByRole("button", { name: "Очистить", exact: true }).click()
await expect(page.locator("tr[data-row-id]")).toHaveCount(2)
await expect(page).toHaveURL(/\/servers$/)
})
for (const value of ["{", JSON.stringify({ v: 99 }), "x".repeat(8193)]) {
test("URL серверов: ошибочный filters длиной " + value.length, async ({ page }) => {
await setup(page, undefined, 2, "/servers?" + new URLSearchParams({ filters: value, server: "1", keep: "1" }))
await expect(page.getByRole("alert").filter({ hasText: "Фильтры требуют внимания" })).toContainText("Не удалось прочитать фильтры")
await expect(page.locator("tr[data-row-id]")).toHaveCount(0)
await page.getByRole("button", { name: "Сбросить фильтры списка", exact: true }).click()
await expect(page).toHaveURL(/server=1&keep=1$/)
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
})
}
test("URL серверов: черновик не публикуется, быстрый поиск не затирает фильтр", async ({ page }) => {
let reads = 0
await setup(page, async (_route, path) => { if (path === "/api/servers") reads++; return false })
const initialReads = reads
await page.getByRole("button", { name: "Фильтры", exact: true }).click()
await page.getByRole("option", { name: "Страна", exact: true }).click()
await page.getByRole("option", { name: "равно", exact: true }).click()
expect(new URL(page.url()).searchParams.has("filters")).toBe(false)
await page.getByRole("option", { name: "DE", exact: true }).click()
await expect.poll(() => new URL(page.url()).searchParams.has("filters")).toBe(true)
const filters = new URL(page.url()).searchParams.get("filters")
const search = page.getByPlaceholder("Поиск по имени, хосту…")
await search.pressSequentially("192.0.2.2", { delay: 0 })
await expect(search).toHaveValue("192.0.2.2")
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("192.0.2.2")
expect(new URL(page.url()).searchParams.get("filters")).toBe(filters)
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
expect(reads).toBe(initialReads)
await page.reload()
await expect(page.getByRole("button", { name: "DE", exact: true })).toBeVisible()
await expect(search).toHaveValue("192.0.2.2")
await expect(page.locator("tr[data-row-id]")).toHaveCount(1)
})