fix(ui): сохранить контекст и фокус контейнеров
No files matched your search
@@ -1,6 +1,8 @@
|
||||
"use client"
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import { useSearchParams } from "next/navigation"
|
||||
import { readContainersUrl, patchContainersUrl, type ContainerStatusFilter } from "@/lib/containers-url-state"
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { routerContainers as mockContainers, servers as mockServers } from "@/lib/data"
|
||||
import type { RouterContainer, Server } from "@/lib/data"
|
||||
@@ -98,6 +100,7 @@ function ExportSheet({ selection, onClose }: {selection: ContainerExport | null;
|
||||
return <CodeExportSheet open={!!selection} onClose={onClose} title="Снимок контейнера" description="Данные каталога; конфигурация устройства и резервная копия в снимок не входят."
|
||||
className="data-[side=right]:w-full data-[side=right]:sm:max-w-xl min-w-0 [&_[data-slot=sheet-footer]]:flex-wrap"
|
||||
beforeCode={<p className="text-xs text-muted-foreground">{selection?.source === "mock" ? "Демонстрационные данные." : "Чтение RouterOS: " + (selection?.collectedAt ? new Date(selection.collectedAt).toLocaleString("ru-RU") : "время неизвестно")}{selection?.failures.length ? " Настройки получены частично; ошибки включены в JSON." : ""}</p>}
|
||||
returnFocus={() => Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === "Действия контейнера " + selection?.container.name) ?? null}
|
||||
formats={formats} />
|
||||
}
|
||||
|
||||
@@ -240,7 +243,8 @@ function ContainerCard({
|
||||
)
|
||||
}
|
||||
|
||||
export default function ContainersPage() {
|
||||
export default function ContainersPage() { return <Suspense fallback={<p role="status">Загрузка контекста контейнеров…</p>}><ContainersSource /></Suspense> }
|
||||
function ContainersSource() {
|
||||
const { mode, backendUrl, prefsHydrated } = useDataSource()
|
||||
if (!prefsHydrated) return <p role="status">Загрузка настроек контейнеров…</p>
|
||||
return <ContainersContent key={JSON.stringify([mode, backendUrl])} />
|
||||
@@ -251,16 +255,35 @@ function ContainersContent() {
|
||||
const canRead = can("mm:dashboard:read")
|
||||
const canReadServers = can("mm:servers:read")
|
||||
const canWrite = can("mm:servers:write")
|
||||
const [search, setSearch] = useState("")
|
||||
const [statusFilter, setStatusFilter] = useState<ContainerView["status"] | "all">("all")
|
||||
const params = useSearchParams()
|
||||
const context = readContainersUrl(params)
|
||||
const search = context.search
|
||||
const statusFilter = context.status
|
||||
const selectedServerId = context.server
|
||||
const urlError = context.error
|
||||
function writeContext(changes: Record<string, string | null>, replace = false) {
|
||||
const href = patchContainersUrl(new URLSearchParams(window.location.search), changes) + window.location.hash
|
||||
if (replace) window.history.replaceState(null, "", href); else window.history.pushState(null, "", href)
|
||||
}
|
||||
const setSearch = (value: string) => writeContext({ q: value }, true)
|
||||
const setStatusFilter = (value: ContainerStatusFilter) => writeContext({ status: value === "all" ? null : value })
|
||||
const setSelectedServerId = (value: string) => writeContext({ server: value === ALL_SERVERS_ID ? null : value })
|
||||
const [exportContainer, setExportContainer] = useState<ContainerExport | null>(null)
|
||||
const [selectedServerId, setSelectedServerId] = useState(ALL_SERVERS_ID)
|
||||
const [state, setState] = useState<ContainersState | null>(null)
|
||||
const [lastStates, setLastStates] = useState<Record<number, ContainerServerState>>({})
|
||||
const [liveServers, setLiveServers] = useState<ContainerServer[]>([])
|
||||
const [loading, setLoading] = useState(isLive && (canRead || canReadServers))
|
||||
const [busyId, setBusyId] = useState<string | null>(null)
|
||||
const [deleteTarget, setDeleteTarget] = useState<ContainerView | null>(null)
|
||||
const deleteReturnLabel = useRef<string | null>(null)
|
||||
useEffect(() => {
|
||||
if (deleteTarget || busyId || !deleteReturnLabel.current) return
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === deleteReturnLabel.current)
|
||||
;(target ?? Array.from(document.querySelectorAll<HTMLButtonElement>("button")).find(button => button.textContent?.trim() === "Обновить"))?.focus()
|
||||
})
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [deleteTarget, busyId])
|
||||
const [operationError, setOperationError] = useState<string | null>(null)
|
||||
const [operationNotice, setOperationNotice] = useState<string | null>(null)
|
||||
const [commandNeedsRead, setCommandNeedsRead] = useState(false)
|
||||
@@ -309,7 +332,7 @@ function ContainersContent() {
|
||||
const relevantStates = state?.servers.filter(node => selectedServerId === ALL_SERVERS_ID || String(node.serverId) === selectedServerId)
|
||||
const known = !isLive || !!state && (selectedServerId === ALL_SERVERS_ID || !!relevantStates?.length) && relevantStates!.every(node => node.availability !== "error")
|
||||
const stale = isLive && !!liveError
|
||||
const writeReady = !isLive || canWrite && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||
const writeReady = !isLive || canWrite && !urlError && !commandNeedsRead && !loading && !stale && !!serversReadAt && !serversError
|
||||
const nodeFresh = (serverId: string) => !isLive || writeReady && state?.servers.some(node => String(node.serverId) === serverId && node.availability === "available" && displayServers.some(server => server.id === serverId))
|
||||
const scoped = useMemo(() => {
|
||||
if (effectiveServerId === ALL_SERVERS_ID) return displayContainers
|
||||
@@ -391,6 +414,7 @@ function ContainersContent() {
|
||||
selectedId={effectiveServerId}
|
||||
onSelect={setSelectedServerId}
|
||||
showAll
|
||||
preserveSelection
|
||||
allCount={displayServers.length}
|
||||
loading={isLive && loading && displayServers.length === 0}
|
||||
header={
|
||||
@@ -418,6 +442,7 @@ function ContainersContent() {
|
||||
>
|
||||
<div className="flex flex-col min-w-0 gap-5 [overflow-wrap:anywhere]">
|
||||
|
||||
{urlError && <Alert role="alert" aria-label="Контекст контейнеров" variant="warning"><AlertDescription>{urlError}<Button type="button" variant="outline" onClick={() => writeContext({server:null,status:null,q:null})}>Сбросить контекст</Button></AlertDescription></Alert>}
|
||||
{operationNotice && <p role="status" className="text-sm [overflow-wrap:anywhere]">{operationNotice}</p>}
|
||||
{operationError && !deleteTarget && <Alert role="alert" aria-label="Команда контейнера" variant="warning"><AlertDescription>{operationError}</AlertDescription></Alert>}
|
||||
{isLive && !canRead && <p role="status">Для каталога контейнеров требуется mm:dashboard:read.</p>}
|
||||
@@ -529,7 +554,7 @@ function ContainersContent() {
|
||||
onStart={() => { void mutate(c, "start") }}
|
||||
onStop={() => { void mutate(c, "stop") }}
|
||||
onRestart={() => { void mutate(c, "restart") }}
|
||||
onRemove={() => { setOperationError(null); setDeleteTarget(c) }}
|
||||
onRemove={() => { deleteReturnLabel.current = "Действия контейнера " + c.name; setOperationError(null); setDeleteTarget(c) }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { useMemo, useState, type ReactNode } from "react"
|
||||
import {
|
||||
Alert,
|
||||
AlertDescription,
|
||||
@@ -29,6 +29,7 @@ import {
|
||||
TriangleAlertIcon,
|
||||
} from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export type CodeExportFormat = {
|
||||
id: string
|
||||
@@ -88,6 +89,7 @@ export interface CodeExportSheetProps {
|
||||
liveLabel?: string
|
||||
className?: string
|
||||
/** Extra content under format tabs (e.g. meta strip) */
|
||||
returnFocus?: () => HTMLElement | null
|
||||
beforeCode?: ReactNode
|
||||
}
|
||||
|
||||
@@ -108,20 +110,18 @@ function CodeExportSheet({
|
||||
liveLabel = "С роутера",
|
||||
className,
|
||||
beforeCode,
|
||||
returnFocus,
|
||||
}: CodeExportSheetProps) {
|
||||
const firstId = formats[0]?.id ?? "default"
|
||||
const [tab, setTab] = useState(initialFormatId ?? firstId)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setTab(
|
||||
initialFormatId && formats.some((f) => f.id === initialFormatId)
|
||||
? initialFormatId
|
||||
: firstId,
|
||||
)
|
||||
setCopied(false)
|
||||
}, [open, initialFormatId, firstId, formats])
|
||||
const initialTab = initialFormatId && formats.some(format => format.id === initialFormatId) ? initialFormatId : firstId
|
||||
const resetKey = JSON.stringify([open, initialTab])
|
||||
const [previousKey, setPreviousKey] = useState(resetKey)
|
||||
const [tab, setTab] = useState(initialTab)
|
||||
const [copiedCode, setCopiedCode] = useState<string | null>(null)
|
||||
if (previousKey !== resetKey) {
|
||||
setPreviousKey(resetKey)
|
||||
if (open) { setTab(initialTab); setCopiedCode(null) }
|
||||
}
|
||||
|
||||
const active = useMemo(
|
||||
() => formats.find((f) => f.id === tab) ?? formats[0],
|
||||
@@ -129,23 +129,26 @@ function CodeExportSheet({
|
||||
)
|
||||
|
||||
const code = active?.code ?? ""
|
||||
const copied = !!code && copiedCode === code
|
||||
const emptyMessage = active?.emptyMessage
|
||||
const filename = active?.filename ?? "export.txt"
|
||||
const canCopy = Boolean(code) && !emptyMessage
|
||||
const isLive = Boolean(active?.isLive)
|
||||
const showTabs = formats.length > 1
|
||||
|
||||
function handleCopy() {
|
||||
async function handleCopy() {
|
||||
if (!canCopy) return
|
||||
void navigator.clipboard.writeText(code).then(() => {
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1800)
|
||||
})
|
||||
setCopiedCode(null)
|
||||
try {
|
||||
await navigator.clipboard.writeText(code)
|
||||
setCopiedCode(code)
|
||||
setTimeout(() => setCopiedCode(null), 1800)
|
||||
} catch { setCopiedCode(null); toast.error("Не удалось скопировать. Повторите действие или скачайте файл.") }
|
||||
}
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={(v) => { if (!v) onClose() }}>
|
||||
<SheetContent
|
||||
<SheetContent finalFocus={returnFocus}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-xl",
|
||||
className,
|
||||
|
||||
@@ -216,3 +216,12 @@ REST запрос → ожидание → подтверждённый output /
|
||||
Этап 10 принят локально (10.71): 235 основных + 52 source E2E, 42 unit, frontend/backend build. Реальный RouterOS/Portal/PostgreSQL остаётся внешней приёмкой. Далее 11А — пользователи, контейнеры, сертификаты, резервирование; затем 11Б и 12. История прежних контрольных точек выше не заменяет актуальный журнал.
|
||||
|
||||
Карта этапа 11А: [API, референсы и схемы](ux-ui-stage-11a.md). Точный следующий блок и ограничения — журнал.
|
||||
|
||||
### Администрирование: экраны 11А
|
||||
|
||||
Пользователи и контейнеры приняты локально (11А.8/11А.13); референсы Settings 16 / Stats 12 / Sheet 8 и схемы — [карта 11А](ux-ui-stage-11a.md). Далее сертификаты и резервирование.
|
||||
|
||||
| Экран | ПК | Телефон |
|
||||
| --- | --- | --- |
|
||||
| Пользователи | [Профиль](ux-ui/screens/users-profile-desktop.png), [ошибка](ux-ui/screens/users-error-desktop.png) | [390](ux-ui/screens/users-profile-mobile.png), [320](ux-ui/screens/users-profile-narrow.png) |
|
||||
| Контейнеры | [Обзор](ux-ui/screens/containers-overview-desktop.png), [снимок](ux-ui/screens/containers-export-desktop.png) | [390](ux-ui/screens/containers-export-mobile.png), [320](ux-ui/screens/containers-export-narrow.png) |
|
||||
@@ -27,7 +27,7 @@
|
||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.12; пользователи приняты локально, UX/экспорт контейнеров проверены |
|
||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.13; пользователи и контейнеры приняты локально, далее сертификаты |
|
||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
|
||||
|
||||
|
||||
## Блок 11А.13 — контекст и локальная приёмка контейнеров (2026-10-01)
|
||||
|
||||
- URL восстанавливает server/status/q, поддерживает Back/reload, сохраняет чужие параметры/hash. Дубли, неверные ID/status и слишком длинный поиск показывают ошибку со сбросом. Использован существующий preserveSelection ServerRail; исчезнувшее устройство не заменяет контекст.
|
||||
- Устранены Clipboard rejection/отсутствие API в общей CodeExportSheet: сообщение с повтором/скачиванием вместо ложного «Скопировано». Бейдж относится к конкретному скопированному тексту; прежний каскадный effect заменён контролируемым сбросом при открытии/изменении начального формата. Дизайн/vendor/theme сохранены.
|
||||
- Закрытие снимка и отмена удаления возвращают фокус к актуальной кнопке карточки; после исчезновения карточки предусмотрен fallback на обновление.
|
||||
- 40 контейнерных E2E, 8 source-context, 10 профильных проверок экспорта, URL unit, TypeScript, профильный lint (включая общую оболочку) и production frontend прошли. Backend state тесты/сборка приняты в 11А.12. Контейнеры приняты локально; создание/логи/редактирование явно disabled, реальный RouterOS/БД требует внешней проверки.
|
||||
- Далее 11А.14: observed state API сертификатов. Исходный mapper делает отсутствующий срок daysLeft=0, status=valid, keySize=0/trusted=false; это нельзя выдавать за наблюдаемые значения. Legacy API/renew/issue должны сохранять совместимость до отдельной миграции; frontend, ACME jobs, настройки и экспорт сертификатов ещё не приняты.
|
||||
|
||||
## Блок 11А.12 — достоверный снимок и интерфейс контейнеров (2026-10-01)
|
||||
|
||||
- Недостоверный генератор RSC (шаблонные IP veth, неподтверждённые настройки и некорректные env команды) заменён JSON-снимком каталога в существующем CodeExportSheet. Файл содержит наблюдаемые поля, collectedAt, source и partial failures; явно подписан как снимок, не конфигурация/backup.
|
||||
|
||||
@@ -144,3 +144,12 @@
|
||||
- 30 уникальных data/operations/layout E2E суммарно прошли (последний полный прогон 29/30, оставшийся narrow исправлен и все 4 layout повторно прошли); 8 context E2E, backend tests/build, TypeScript, профильный lint и production frontend прошли. Скачанный JSON проверен; 6 снимков сохранены и просмотрены.
|
||||
- ReUI checklist проверен в границах адаптации. Далее 11А.13: URL-контекст и clipboard отказ/фокус, затем локальная приёмка контейнеров и аудит сертификатов. Общие ошибки Clipboard в CodeExportSheet требуют обработки; реальный RouterOS/БД ещё не проверен.
|
||||
|
||||
|
||||
## Блок 11А.13 — контекст и локальная приёмка контейнеров (2026-10-01)
|
||||
|
||||
- URL восстанавливает server/status/q, поддерживает Back/reload, сохраняет чужие параметры/hash. Дубли, неверные ID/status и слишком длинный поиск показывают ошибку со сбросом. Использован существующий preserveSelection ServerRail; исчезнувшее устройство не заменяет контекст.
|
||||
- Устранены Clipboard rejection/отсутствие API в общей CodeExportSheet: сообщение с повтором/скачиванием вместо ложного «Скопировано». Бейдж относится к конкретному скопированному тексту; прежний каскадный effect заменён контролируемым сбросом при открытии/изменении начального формата. Дизайн/vendor/theme сохранены.
|
||||
- Закрытие снимка и отмена удаления возвращают фокус к актуальной кнопке карточки; после исчезновения карточки предусмотрен fallback на обновление.
|
||||
- 40 контейнерных E2E, 8 source-context, 10 профильных проверок экспорта, URL unit, TypeScript, профильный lint (включая общую оболочку) и production frontend прошли. Backend state тесты/сборка приняты в 11А.12. Контейнеры приняты локально; создание/логи/редактирование явно disabled, реальный RouterOS/БД требует внешней проверки.
|
||||
- Далее 11А.14: observed state API сертификатов. Исходный mapper делает отсутствующий срок daysLeft=0, status=valid, keySize=0/trusted=false; это нельзя выдавать за наблюдаемые значения. Legacy API/renew/issue должны сохранять совместимость до отдельной миграции; frontend, ACME jobs, настройки и экспорт сертификатов ещё не приняты.
|
||||
|
||||
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 253 KiB |
|
Before Width: | Height: | Size: 146 KiB After Width: | Height: | Size: 147 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
@@ -0,0 +1,7 @@
|
||||
import assert from "node:assert/strict"
|
||||
import {readContainersUrl,patchContainersUrl} from "./containers-url-state"
|
||||
for(const q of ["server=bad","server=0","server=1.5","server=9007199254740992","server=1&server=2","status=bad","status=running&status=stopped","q=a&q=b","q="+"x".repeat(201)])assert.ok(readContainersUrl(new URLSearchParams(q)).error,q)
|
||||
assert.deepEqual(readContainersUrl(new URLSearchParams("server=2&status=unknown&q=test")),{server:"2",status:"unknown",search:"test",error:null})
|
||||
assert.deepEqual(readContainersUrl(new URLSearchParams()),{server:"all",status:"all",search:"",error:null})
|
||||
assert.equal(patchContainersUrl(new URLSearchParams("server=1&status=running&ref=map"),{server:null,status:null}),"/containers?ref=map")
|
||||
console.log("Containers URL: strict/duplicate context, empty defaults, unrelated parameters preserved")
|
||||
@@ -0,0 +1,16 @@
|
||||
export type ContainerStatusFilter = "all" | "running" | "stopped" | "error" | "unknown"
|
||||
const statuses = ["running","stopped","error","unknown"]
|
||||
export function readContainersUrl(params: URLSearchParams) {
|
||||
const server = params.get("server"), status = params.get("status"), search = params.get("q") ?? ""
|
||||
let error: string | null = null
|
||||
if (["server","status","q"].some(key=>params.getAll(key).length>1)) error="Параметры контекста контейнеров повторяются."
|
||||
if(server!==null && (!/^[1-9]\d*$/.test(server)||!Number.isSafeInteger(Number(server)))) error="В ссылке указан неверный сервер."
|
||||
if(status!==null&&!statuses.includes(status))error="В ссылке указан неизвестный статус."
|
||||
if(search.length>200)error="Поиск в ссылке слишком длинный."
|
||||
return {server:server??"all",status:(status??"all") as ContainerStatusFilter,search,error}
|
||||
}
|
||||
export function patchContainersUrl(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 "/containers"+(next.size?"?"+next.toString():"")
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import {test,expect,type Page} from "@playwright/test"
|
||||
import {container,server,state} from "./fixtures/containers"
|
||||
test.beforeEach(async({page})=>{await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>r.fulfill({json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/servers"?[server]:state()}))})
|
||||
for(const query of ["server=bad","server=1&server=2","status=bad","q=a&q=b"])test("Контейнеры URL: ошибочный контекст "+query,async({page})=>{
|
||||
await page.goto("/containers?"+query+"&keep=map#detail");await expect(page.getByRole("alert",{name:"Контекст контейнеров",exact:true})).toBeVisible();await page.getByRole("button",{name:"Сбросить контекст",exact:true}).click();await expect(page).toHaveURL(/containers\?keep=map#detail$/);await expect(page.getByText(container.name,{exact:true})).toBeVisible()
|
||||
})
|
||||
test("Контейнеры URL: фильтр, reload и Back сохраняют контекст",async({page})=>{
|
||||
await page.goto("/containers?server=1&status=running&q=container&keep=map#detail");await expect(page.getByText(container.name,{exact:true})).toBeVisible();await expect(page.getByRole("textbox",{name:"Поиск контейнеров"})).toHaveValue("container");const group=page.getByRole("group",{name:"Статус контейнеров"});await group.getByRole("button",{name:"Остановлен",exact:true}).click();await expect(page).toHaveURL(/status=stopped/);await expect(page.getByText("Измените фильтр или поиск",{exact:true})).toBeVisible();await page.goBack();await expect(page.getByText(container.name,{exact:true})).toBeVisible();await page.reload();await expect(page.getByRole("textbox",{name:"Поиск контейнеров"})).toHaveValue("container");await expect(group.getByRole("button",{name:"Работает",exact:true})).toHaveAttribute("aria-pressed","true");await expect(page).toHaveURL(/keep=map#detail$/)
|
||||
})
|
||||
test("Контейнеры URL: недоступный сервер не заменяется всеми",async({page})=>{
|
||||
await page.goto("/containers?server=2&keep=map");await expect(page.getByRole("alert").filter({hasText:"Выбранный сервер недоступен"})).toBeVisible();await expect(page.getByText(container.name,{exact:true})).toHaveCount(0);await expect(page).toHaveURL(/server=2/)
|
||||
})
|
||||
async function openExport(page:Page){await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:"Снимок JSON",exact:true}).click();return page.getByRole("dialog")}
|
||||
for(const absent of [false,true])test("Контейнеры: Clipboard отказ "+absent,async({page})=>{
|
||||
await page.addInitScript(absent=>{Object.defineProperty(navigator,"clipboard",{configurable:true,value:absent?undefined:{writeText:async()=>{throw new Error("denied")}}})},absent)
|
||||
const dialog=await openExport(page);await dialog.getByRole("button",{name:"Копировать",exact:true}).click();await expect(page.getByText("Не удалось скопировать. Повторите действие или скачайте файл.",{exact:true})).toBeVisible();await expect(dialog.getByRole("button",{name:"Скопировано",exact:true})).toHaveCount(0);await expect(dialog.getByRole("button",{name:"Файл",exact:true})).toBeEnabled()
|
||||
})
|
||||
test("Контейнеры: успешный Clipboard и возврат фокуса",async({page})=>{
|
||||
await page.addInitScript(()=>{Object.defineProperty(navigator,"clipboard",{configurable:true,value:{writeText:async(value:string)=>{(window as unknown as {copied:string}).copied=value}}})})
|
||||
const dialog=await openExport(page);await dialog.getByRole("button",{name:"Копировать",exact:true}).click();await expect(dialog.getByRole("button",{name:"Скопировано",exact:true})).toBeVisible();expect(await page.evaluate(()=>(window as unknown as {copied:string}).copied)).toContain(container.name);await dialog.getByRole("button",{name:"Закрыть",exact:true}).first().click();await expect(page.getByRole("button",{name:"Действия контейнера "+container.name})).toBeFocused()
|
||||
})
|
||||
test("Контейнеры: отмена удаления возвращает фокус",async({page})=>{
|
||||
await page.goto("/containers");await page.getByRole("button",{name:"Действия контейнера "+container.name}).click();await page.getByRole("menuitem",{name:"Удалить",exact:true}).click();await page.getByRole("alertdialog").getByRole("button",{name:"Отмена",exact:true}).click();await expect(page.getByRole("button",{name:"Действия контейнера "+container.name})).toBeFocused()
|
||||
})
|
||||