fix(ui): сохранять фильтры серверов в адресе страницы
This commit is contained in:
1 parent
b64e6834a9
commit
dfb993e1a3
7 files changed
+256
-10
No files matched your search
@@ -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
@@ -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. Список ниже сохранён как историческая инвентаризация.
|
||||||
|
|||||||
@@ -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 и чужие параметры сохраняются.
|
||||||
@@ -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 }) },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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() : "")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
Reference in new issue
Block a user