fix(ui): улучшить клавиатурный ввод Community в Filters
This commit is contained in:
1 parent
4ae5f2edcd
commit
29a4ea2e5b
10 files changed
+130
-37
No files matched your search
+22
-36
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useMemo, useState, useCallback, useEffect, useRef } from "react"
|
||||
import { useMemo, useState, useCallback, useEffect, useRef, useId } from "react"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { StatusDot } from "@/components/status-dot"
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { DataPageCard } from "@/components/data-page-card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Combobox, ComboboxInput, ComboboxContent, ComboboxList, ComboboxItem, ComboboxEmpty } from "@/components/ui/combobox"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { filtersResponseSchema, filtersMutationSchema, filtersGreResponseSchema, filtersRecursiveResponseSchema } from "@/lib/filters-response"
|
||||
@@ -407,40 +408,21 @@ function RecursiveGatewayCards({
|
||||
|
||||
// ── community autocomplete ─────────────────────────────────────────────────────
|
||||
|
||||
function CommunityInput({
|
||||
value,
|
||||
onChange,
|
||||
suggestions,
|
||||
nameMap,
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
suggestions: string[]
|
||||
nameMap: Record<string, string>
|
||||
function CommunityInput({ value, onChange, suggestions, nameMap, inputId, disabled }: {
|
||||
value: string; onChange: (v: string) => void; suggestions: string[]; nameMap: Record<string, string>
|
||||
inputId: string; disabled: boolean
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const filtered = suggestions.filter(c =>
|
||||
c.toLowerCase().includes(value.toLowerCase()) ||
|
||||
(nameMap[c] ?? "").toLowerCase().includes(value.toLowerCase())
|
||||
)
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input value={value} onChange={e => { onChange(e.target.value); setOpen(true) }}
|
||||
onFocus={() => setOpen(true)} onBlur={() => setTimeout(() => setOpen(false), 150)}
|
||||
placeholder="65001:100" className="font-mono h-9" />
|
||||
{open && filtered.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-1 rounded-md border bg-popover shadow-md overflow-hidden max-h-56 overflow-y-auto">
|
||||
{filtered.map(c => (
|
||||
<button key={c} type="button" onMouseDown={() => { onChange(c); setOpen(false) }}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-accent text-left">
|
||||
<span className="font-mono text-xs font-medium">{c}</span>
|
||||
<span className="text-xs text-muted-foreground truncate">{nameMap[c] ?? ""}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
return <Combobox items={suggestions} value={value} inputValue={value} disabled={disabled}
|
||||
onInputValueChange={onChange} onValueChange={v => { if (v !== null) onChange(v) }}
|
||||
filter={(item, query) => item.toLowerCase().includes(query.toLowerCase()) || (nameMap[item] ?? "").toLowerCase().includes(query.toLowerCase())}>
|
||||
<ComboboxInput id={inputId} disabled={disabled} showTrigger={false} placeholder="65001:100" className="h-9 font-mono" />
|
||||
<ComboboxContent>
|
||||
<ComboboxEmpty>Нет подсказок — можно ввести Community вручную</ComboboxEmpty>
|
||||
<ComboboxList>{(community: string) => <ComboboxItem key={community} value={community}>
|
||||
<span className="font-mono text-xs">{community}</span><span className="truncate text-xs text-muted-foreground">{nameMap[community]}</span>
|
||||
</ComboboxItem>}</ComboboxList>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
}
|
||||
|
||||
// ── rule sheet ─────────────────────────────────────────────────────────────────
|
||||
@@ -480,6 +462,7 @@ function RuleSheet({
|
||||
evoHint?: string | null
|
||||
recursiveRoutes: RecursiveRouteLite[]
|
||||
}) {
|
||||
const communityInputId = useId()
|
||||
const [form, setForm] = useState<RuleForm>(initial)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [touched, setTouched] = useState(false)
|
||||
@@ -566,11 +549,13 @@ function RuleSheet({
|
||||
|
||||
{/* ── BGP Community ── */}
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-xs font-medium flex items-center gap-1">
|
||||
<label htmlFor={communityInputId} className="text-xs font-medium flex items-center gap-1">
|
||||
<TagIcon className="size-3 text-muted-foreground" />
|
||||
BGP Community <span className="text-destructive ml-0.5">*</span>
|
||||
</label>
|
||||
<CommunityInput
|
||||
inputId={communityInputId}
|
||||
disabled={saving}
|
||||
value={form.community}
|
||||
onChange={handleCommunityChange}
|
||||
suggestions={communitySuggestions}
|
||||
@@ -603,6 +588,7 @@ function RuleSheet({
|
||||
{(["route", "blackhole"] as const).map(a => (
|
||||
<button
|
||||
key={a}
|
||||
aria-pressed={form.action === a}
|
||||
type="button"
|
||||
onClick={() => set("action", a)}
|
||||
className={cn(
|
||||
@@ -743,7 +729,7 @@ function RuleSheet({
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md">
|
||||
<div role="alert" className="flex items-center gap-2 text-sm text-destructive bg-destructive/10 border border-destructive/20 px-3 py-2 rounded-md">
|
||||
<AlertCircleIcon className="size-4 shrink-0" />{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -92,6 +92,15 @@ flowchart LR
|
||||
2. Сохранять контекст устройства, введённые значения при ошибке и подтверждать результат операций по API; локальные моки проверяют клиентские сценарии, реальные сетевые операции требуют тестового окружения.
|
||||
3. В этапе 12 выполнить полный аудит доступности (скринридер, контраст), производительности и сквозную регрессию. Ограничения этапа 8 — удалённые серверы месячного каталога, отсутствие экспорта и автоматического переподключения SSE — остаются явно зафиксированными, без заявления о реализации.
|
||||
|
||||
## Проверенные экраны Filters — блок 9.46
|
||||
|
||||
| Референс плана | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
| Data Grid Filtering 2: устройство → свежесть → таблица | [Экран](ux-ui/screens/filters-desktop.png) | [Экран](ux-ui/screens/filters-mobile.png) |
|
||||
| Sheet 8: Community → действие → подтверждение API | [Форма](ux-ui/screens/filters-rule-desktop.png) | [Форма](ux-ui/screens/filters-rule-mobile.png) |
|
||||
|
||||
Снимки HTTP-фикстур просмотрены; профильные проверки клавиатуры/адаптивности и сохранения прошли. Этап 9 остаётся в работе; следующие блоки — ограничения IPsec/VXLAN и общего GRE-каталога по журналу.
|
||||
|
||||
## Порядок продолжения и журналирования
|
||||
|
||||
Перед работой читать этот план, начало журнала, git status и последние коммиты; сохранять чужие изменения. Затем проверить квоту и выбрать завершённый по смыслу блок текущего этапа.
|
||||
|
||||
+14
-1
@@ -25,12 +25,25 @@
|
||||
| 6 | Навигация и дашборд | Завершён 2026-09-20 в границах локальной приёмки; ограничения ниже |
|
||||
| 7 | Серверы и мониторинг: детали, формы, URL-контекст, права | Завершён в границах локальной приёмки 2026-09-23; внешние ограничения ниже |
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.45 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 9 | VPN, Firewall и Filters | В работе: блоки 9.1–9.46 — IPsec/GRE/VXLAN, Firewall и операции Filters |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Не начат |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | Не начат |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
## Блок 9.46 — клавиатура и экраны Filters (2026-09-30)
|
||||
|
||||
- Community использует установленный Base UI Combobox: фильтрация по коду/названию, выбор стрелками/Enter и свободный ввод. Поле связано с подписью, действие отмечено aria-pressed, ошибка объявляется alert; ввод блокируется при сохранении. Tab закрывает подсказки и возвращает доступ к остальным полям формы. При открытом popup Base UI временно ограничивает дерево доступности; тесты проверяют переход Tab, а не обходят его.
|
||||
- Два новых E2E проверяют ПК 1440×1000 и телефон 390×844: отсутствие переполнения документа, выбор устройства на телефоне, валидацию, клавиатурный выбор/ручной ввод и единственный PUT в правильное устройство. Четыре PNG сохранены и просмотрены. Таблица на телефоне использует внутреннюю прокрутку; это не полная приёмка контраста, screen reader или всех форм шлюзов.
|
||||
- 13 E2E прошли: layout/сохранение (4), копирование/актуальная цель/удаление/порядок/свежесть (9). TypeScript прошёл; lint 0 ошибок/13 прежних предупреждений страницы, тесты без замечаний. Build не повторялся, последние frontend/backend — 9.44. Моки HTTP проверяют live-путь; реальные RouterOS/JWT/БД не проверены.
|
||||
- Далее — оставшиеся ограничения этапа 9: первоначальные/частичные сбои IPsec, свежесть VXLAN и ошибки общего GRE-каталога. Этап 9 открыт. Перед новым блоком проверить квоту и журнал; этапы 10–12 не начаты.
|
||||
- Квота после профильных проверок: 96% пятичасовой/15% недельной. Пользователь разрешил работу до конца лимитов. Локальный коммит без push.
|
||||
|
||||
| Образец | Проверенный экран | Применённая композиция |
|
||||
| --- | --- | --- |
|
||||
| ReUI Data Grid Filtering 2 | [Filters ПК](ux-ui/screens/filters-desktop.png), [телефон](ux-ui/screens/filters-mobile.png) | Устройство → свежесть → правила → действие |
|
||||
| ReUI Sheet 8 | [Форма ПК](ux-ui/screens/filters-rule-desktop.png), [телефон](ux-ui/screens/filters-rule-mobile.png) | Community → действие → пояснение → подтверждение API |
|
||||
|
||||
## Блок 9.45 — свежесть Filters (2026-09-30)
|
||||
|
||||
- Экран показывает время успешного получения правил, GRE и recursive каталогов выбранного устройства. До первого успеха время неизвестно; ошибочное/устаревшее чтение не обновляет время, успешный пустой каталог обновляет. Подтверждённая запись подписана отдельно «Применение подтверждено API», не выдаётся за опрос RouterOS. Read/write время сохраняется по serverId, сбрасывается вместе с изолированным источником.
|
||||
|
||||
@@ -221,3 +221,16 @@ Live-копирование читает актуальный целевой rul
|
||||
## Блок 9.45 — свежесть Filters (2026-09-30)
|
||||
|
||||
По устройству показано клиентское время получения правил/GRE/recursive и отдельное подтверждение записи. Ошибка сохраняет время; неизвестный первоначальный срез не получает ложную дату. Десять E2E и TypeScript прошли; новых lint замечаний нет. Далее — клавиатура/телефон и проверенные экраны Filters. Build frontend/backend — 9.44.
|
||||
|
||||
## Блок 9.46 — клавиатура и экраны Filters (2026-09-30)
|
||||
|
||||
- Community использует установленный Base UI Combobox: фильтрация по коду/названию, выбор стрелками/Enter и свободный ввод. Поле связано с подписью, действие отмечено aria-pressed, ошибка объявляется alert; ввод блокируется при сохранении. Tab закрывает подсказки и возвращает доступ к остальным полям формы. При открытом popup Base UI временно ограничивает дерево доступности; тесты проверяют переход Tab, а не обходят его.
|
||||
- Два новых E2E проверяют ПК 1440×1000 и телефон 390×844: отсутствие переполнения документа, выбор устройства на телефоне, валидацию, клавиатурный выбор/ручной ввод и единственный PUT в правильное устройство. Четыре PNG сохранены и просмотрены. Таблица на телефоне использует внутреннюю прокрутку; это не полная приёмка контраста, screen reader или всех форм шлюзов.
|
||||
- 13 E2E прошли: layout/сохранение (4), копирование/актуальная цель/удаление/порядок/свежесть (9). TypeScript прошёл; lint 0 ошибок/13 прежних предупреждений страницы, тесты без замечаний. Build не повторялся, последние frontend/backend — 9.44. Моки HTTP проверяют live-путь; реальные RouterOS/JWT/БД не проверены.
|
||||
- Далее — оставшиеся ограничения этапа 9: первоначальные/частичные сбои IPsec, свежесть VXLAN и ошибки общего GRE-каталога. Этап 9 открыт. Перед новым блоком проверить квоту и журнал; этапы 10–12 не начаты.
|
||||
- Квота после профильных проверок: 96% пятичасовой/15% недельной. Пользователь разрешил работу до конца лимитов. Локальный коммит без push.
|
||||
|
||||
| Образец | Проверенный экран | Применённая композиция |
|
||||
| --- | --- | --- |
|
||||
| ReUI Data Grid Filtering 2 | [Filters ПК](ux-ui/screens/filters-desktop.png), [телефон](ux-ui/screens/filters-mobile.png) | Устройство → свежесть → правила → действие |
|
||||
| ReUI Sheet 8 | [Форма ПК](ux-ui/screens/filters-rule-desktop.png), [телефон](ux-ui/screens/filters-rule-mobile.png) | Community → действие → пояснение → подтверждение API |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 64 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 254 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 95 KiB |
@@ -0,0 +1,71 @@
|
||||
import { test, expect } from "@playwright/test"
|
||||
import { mkdirSync } from "node:fs"
|
||||
|
||||
for (const device of ["desktop", "mobile"] as const) {
|
||||
test(`Filters: клавиатурный ввод и layout ${device}`, async ({ page }) => {
|
||||
await page.setViewportSize(device === "desktop" ? { width: 1440, height: 1000 } : { width: 390, height: 844 })
|
||||
await page.clock.install({ time: new Date("2026-09-30T01:00:00Z") })
|
||||
const rulesFor = (id: string) => [
|
||||
{ id: id + "-youtube", community: "65001:100", gateway: "10.0.0.2", gatewayTunnelId: "t" + id, action: "route", description: "Видеосервисы через основной шлюз" },
|
||||
{ id: id + "-streaming", community: "65001:200", gateway: "10.0.0.2", gatewayTunnelId: "t" + id, action: "route", description: "Потоковые сервисы" },
|
||||
{ id: id + "-blackhole", community: "65535:666", gateway: "", gatewayTunnelId: "", action: "blackhole", description: "RTBH — защита от DDoS" },
|
||||
]
|
||||
const rulesets = [{ serverId: "1", rules: rulesFor("1") }, { serverId: "2", rules: rulesFor("2") }]
|
||||
const tunnel = (id: string) => ({ id: "t" + id, serverId: id, name: "gre-core-edge", localAddress: "192.0.2." + id, remoteAddress: "198.51.100.1", localInnerIp: "10.0.0.1/30", remoteInnerIp: "10.0.0.2/30", poolId: "", ipsec: null, mtu: 1476, keepaliveInterval: 10, keepaliveRetries: 3, dscp: "inherit", clampTcpMss: true, allowFastPath: true, comment: "", enabled: true, status: "up" })
|
||||
let writes = 0
|
||||
await page.route("**/health", route => route.fulfill({ json: { ok: true } }))
|
||||
await page.route("**/api/**", route => {
|
||||
const url = new URL(route.request().url()), path = url.pathname, id = url.searchParams.get("serverId")
|
||||
if (path === "/api/filters/rules" && route.request().method() === "PUT") {
|
||||
writes++
|
||||
const body = route.request().postDataJSON()
|
||||
expect(body.serverId).toBe(device === "mobile" ? "2" : "1")
|
||||
expect(body.rules.at(-1)).toMatchObject({ community: "65001:777", action: "blackhole", description: "Новый фильтр", gateway: "", gatewayTunnelId: "" })
|
||||
rulesets.find(rs => rs.serverId === body.serverId)!.rules = body.rules
|
||||
return route.fulfill({ json: { ok: true, rules: body.rules } })
|
||||
}
|
||||
return route.fulfill({ json: path === "/api/filters/rules" ? { rulesets: id ? rulesets.filter(rs => rs.serverId === id) : rulesets, greTunnels: [], live: true }
|
||||
: path === "/api/servers" ? [1, 2].map(i => ({ id: i, name: i === 1 ? "Основной роутер" : "Резервный роутер", host: "192.0.2." + i, type: "exit-node", enabled: true, country: "RU", site: "CORE", asn: "65001", status: "online", latency: 3 }))
|
||||
: path === "/api/filters/gre-tunnels" ? { tunnels: [tunnel(id ?? "1")] }
|
||||
: path === "/api/recursive-routes" ? { routes: [], stale: false }
|
||||
: path === "/api/auth/config" ? { required: false } : {} })
|
||||
})
|
||||
await page.goto("/filters")
|
||||
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(3)
|
||||
await expect(page.getByRole("group", { name: "Свежесть данных устройства" }).locator("time")).toHaveCount(3)
|
||||
expect(await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true)
|
||||
await page.addStyleTag({ content: "nextjs-portal { display: none !important; }" })
|
||||
mkdirSync("docs/ux-ui/screens", { recursive: true })
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/filters-${device}.png` })
|
||||
if (device === "mobile") {
|
||||
await page.getByRole("button", { name: "Серверы", exact: true }).click()
|
||||
const rail = page.getByRole("dialog")
|
||||
await rail.getByRole("option", { name: /Резервный роутер/ }).click()
|
||||
await expect(rail).toHaveCount(0)
|
||||
await expect(page.getByRole("button", { name: "Новое правило", exact: true })).toBeEnabled()
|
||||
}
|
||||
const create = page.getByRole("button", { name: "Новое правило", exact: true })
|
||||
await create.focus()
|
||||
await page.keyboard.press("Enter")
|
||||
const dialog = page.getByRole("dialog")
|
||||
await dialog.getByRole("button", { name: "Добавить", exact: true }).click()
|
||||
await expect(dialog.getByRole("alert")).toContainText("BGP Community обязательно")
|
||||
const community = dialog.getByRole("combobox", { name: /BGP Community/ })
|
||||
await community.fill("65535")
|
||||
await page.keyboard.press("ArrowDown")
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(community).toHaveValue("65535:666")
|
||||
await expect(dialog.getByRole("button", { name: /⊘ Blackhole/ })).toHaveAttribute("aria-pressed", "true")
|
||||
await community.fill("65001:777")
|
||||
await page.keyboard.press("Tab")
|
||||
await dialog.getByRole("textbox", { name: "Описание правила" }).fill("Новый фильтр")
|
||||
expect(await dialog.evaluate(el => el.getBoundingClientRect().right <= window.innerWidth && el.getBoundingClientRect().left >= 0)).toBe(true)
|
||||
await page.screenshot({ path: `docs/ux-ui/screens/filters-rule-${device}.png` })
|
||||
await dialog.getByRole("button", { name: "Добавить", exact: true }).focus()
|
||||
await page.keyboard.press("Enter")
|
||||
await expect(dialog).toHaveCount(0)
|
||||
await expect(page.locator("tr[data-row-id]")).toHaveCount(4)
|
||||
expect(writes).toBe(1)
|
||||
})
|
||||
}
|
||||
@@ -42,6 +42,7 @@ for (const editing of [false, true]) {
|
||||
const dialog = page.getByRole("dialog")
|
||||
if (!editing) {
|
||||
await dialog.getByPlaceholder("65001:100").fill("65001:999")
|
||||
await page.keyboard.press("Tab")
|
||||
await dialog.getByRole("button", { name: /⊘ Blackhole/ }).click()
|
||||
}
|
||||
const description = dialog.getByRole("textbox", { name: "Описание правила" })
|
||||
|
||||
Reference in new issue
Block a user