diff --git a/app/(main)/servers/page.tsx b/app/(main)/servers/page.tsx index d7eb78c7..b671e7da 100644 --- a/app/(main)/servers/page.tsx +++ b/app/(main)/servers/page.tsx @@ -11,7 +11,7 @@ import { PageHeader } from "@/components/page-header" import { FormField, FormToggle, SectionTitle, SegmentedControl } from "@/components/form-kit" import { DataPageToolbar } from "@/components/data-page-toolbar" 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 { applyFilterQuery } from "@/lib/data-filters/apply-filter-query" import { SERVER_FILTER_ACCESSORS } from "@/lib/data-filters/server-filter-fields" @@ -231,9 +231,7 @@ function ServersPageContent() { const [serverList, setServerList] = useState([]) const [listState, setListState] = useState<"loading" | "ready" | "error">("loading") - const [search, setSearch] = useState("") - const [typeFilter, setTypeFilter] = useState("all") - const [advancedFilters, setAdvancedFilters] = useState(() => createFilterQuery()) + const { search, setSearch, typeFilter, setTypeFilter, filters: advancedFilters, setFilters: setAdvancedFilters, error: urlError, hasFilters, reset: resetFilters } = useServerUrlState() const [open, setOpen] = useState(false) const [sheetMode, setSheetMode] = useState("add") const [editingId, setEditingId] = useState(null) @@ -561,6 +559,7 @@ function ServersPageContent() { const selectedServer = serverList.find(server => server.id === selectedServerId) const filtered = useMemo(() => { + if (urlError) return [] const base = serverList.filter(sv => { if (selectedServerId !== null && sv.id !== selectedServerId) 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 applyFilterQuery(base, advancedFilters, SERVER_FILTER_ACCESSORS) - }, [serverList, search, typeFilter, advancedFilters, selectedServerId]) + }, [serverList, search, typeFilter, advancedFilters, selectedServerId, urlError]) const counts = useMemo(() => ({ all: serverList.length, @@ -668,6 +667,8 @@ function ServersPageContent() { /> {/* Table */} + {urlError && Фильтры требуют внимания{urlError}} + {hasFilters &&
} 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, 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 }) }, + } +} diff --git a/lib/data-filters/server-url-state.test.ts b/lib/data-filters/server-url-state.test.ts new file mode 100644 index 00000000..9d54126f --- /dev/null +++ b/lib/data-filters/server-url-state.test.ts @@ -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) +}) diff --git a/lib/data-filters/server-url-state.ts b/lib/data-filters/server-url-state.ts new file mode 100644 index 00000000..0922d27b --- /dev/null +++ b/lib/data-filters/server-url-state.ts @@ -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 => typeof value === "object" && value !== null && !Array.isArray(value) + +export function validateServerQuery(value: unknown): "valid" | "draft" | "invalid" { + const ids = new Set() + 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) { + 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() : "") +} diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index d772eb6f..0e256897 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -362,3 +362,82 @@ test("изменение подключения сбрасывает резул await page.getByPlaceholder("api-user", { exact: true }).fill("changed-user") 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) +})