fix(ui): завершить доступность формы пользователей
No files matched your search
@@ -96,8 +96,18 @@ function UsersContent() {
|
|||||||
const mutationController = useRef<AbortController | null>(null)
|
const mutationController = useRef<AbortController | null>(null)
|
||||||
const alive = useRef(true)
|
const alive = useRef(true)
|
||||||
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
useEffect(() => { alive.current = true; return () => { alive.current = false; mutationController.current?.abort() } }, [])
|
||||||
|
const formReturnFocus = useRef<HTMLElement | null>(null)
|
||||||
const [sheetOpen, setSheetOpen] = useState(false)
|
const [sheetOpen, setSheetOpen] = useState(false)
|
||||||
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
const [editUser, setEditUser] = useState<AppUser | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
if (sheetOpen || !formReturnFocus.current) return
|
||||||
|
const label = formReturnFocus.current.getAttribute("aria-label")
|
||||||
|
const frame = requestAnimationFrame(() => {
|
||||||
|
const target = Array.from(document.querySelectorAll<HTMLButtonElement>("button[aria-label]")).find(button => button.getAttribute("aria-label") === label)
|
||||||
|
;(target ?? document.querySelector<HTMLButtonElement>('button[aria-label="Добавить пользователя"]'))?.focus()
|
||||||
|
})
|
||||||
|
return () => cancelAnimationFrame(frame)
|
||||||
|
}, [sheetOpen])
|
||||||
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
const [deleteTarget, setDeleteTarget] = useState<AppUser | null>(null)
|
||||||
|
|
||||||
const loadLive = useCallback(async () => {
|
const loadLive = useCallback(async () => {
|
||||||
@@ -236,20 +246,20 @@ function UsersContent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full min-w-0">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
crumbs={[{ label: "Управление" }, { label: "Пользователи" }]}
|
||||||
actions={
|
actions={
|
||||||
<>
|
<>
|
||||||
{isLive && <Button type="button" size="sm" variant="outline" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить пользователей</Button>}
|
{isLive && <Button type="button" size="sm" variant="outline" aria-label="Обновить пользователей" disabled={!canRead || loading || saving || deleting || sheetOpen || !!deleteTarget} onClick={() => void loadLive()}>Обновить</Button>}
|
||||||
<Button type="button" size="sm" disabled={!writeReady || saving || deleting} onClick={() => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
<Button type="button" size="sm" aria-label="Добавить пользователя" disabled={!writeReady || saving || deleting} onClick={() => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(null); setSheetOpen(true) }}>
|
||||||
<PlusIcon className="size-4" />Добавить пользователя
|
<PlusIcon aria-hidden="true" className="size-4" />Добавить <span className="hidden sm:inline">пользователя</span>
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-6 flex flex-col gap-5">
|
<div className="flex-1 overflow-y-auto p-4 md:p-6 min-w-0 flex flex-col gap-5">
|
||||||
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
|
{operationNotice && <p role="status" className="text-sm text-muted-foreground">{operationNotice}</p>}
|
||||||
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
|
{!isLive && <p role="status" className="text-sm text-muted-foreground">Демо: изменения сохраняются только в текущем экране.</p>}
|
||||||
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
|
{!canRead && <p role="status">Для чтения пользователей требуется mm:users:read.</p>}
|
||||||
@@ -295,13 +305,14 @@ function UsersContent() {
|
|||||||
allSectionsCount={ALL_SECTIONS.length}
|
allSectionsCount={ALL_SECTIONS.length}
|
||||||
isLoading={loading && !usersKnown}
|
isLoading={loading && !usersKnown}
|
||||||
actionsDisabled={!writeReady || saving || deleting}
|
actionsDisabled={!writeReady || saving || deleting}
|
||||||
onEdit={(u) => { saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
onEdit={(u) => { formReturnFocus.current = document.activeElement as HTMLElement; saveCheckpoint.current = null; setSaveError(null); setSaveBlocked(false); setSaveResume(false); setEditUser(u); setSheetOpen(true) }}
|
||||||
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
onDelete={u => { setDeleteError(null); setDeleteTarget(u) }}
|
||||||
/>
|
/>
|
||||||
</DataPageCard>}
|
</DataPageCard>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<UserSheet
|
<UserSheet
|
||||||
|
returnFocus={formReturnFocus}
|
||||||
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
key={sheetOpen ? (editUser?.id ?? "create") : "closed"}
|
||||||
open={sheetOpen}
|
open={sheetOpen}
|
||||||
user={editUser}
|
user={editUser}
|
||||||
|
|||||||
@@ -31,9 +31,9 @@ import {
|
|||||||
|
|
||||||
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
function AvatarCircle({ avatar, active }: { avatar: string; active: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div aria-hidden="true"
|
||||||
className={cn(
|
className={cn(
|
||||||
"size-8 rounded-full flex items-center justify-center text-white text-[10px] font-semibold shrink-0 bg-gradient-to-br from-blue-500 to-violet-500",
|
"size-8 rounded-full flex items-center justify-center text-foreground text-[10px] font-semibold shrink-0 bg-muted",
|
||||||
!active && "opacity-50",
|
!active && "opacity-50",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -74,11 +74,13 @@ function UsersDataGrid({
|
|||||||
const expanded = row.getIsExpanded()
|
const expanded = row.getIsExpanded()
|
||||||
return (
|
return (
|
||||||
<div className="flex items-start gap-2 min-w-0">
|
<div className="flex items-start gap-2 min-w-0">
|
||||||
|
<Button type="button" size="sm" variant="ghost" aria-label={"Доступ и привязки пользователя " + u.name} aria-expanded={expanded} className="size-8 shrink-0 p-0" onClick={event => { event.stopPropagation(); row.toggleExpanded() }}>
|
||||||
{expanded ? (
|
{expanded ? (
|
||||||
<ChevronDownIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
<ChevronDownIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground" />
|
||||||
) : (
|
) : (
|
||||||
<ChevronRightIcon className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
<ChevronRightIcon aria-hidden="true" className="size-3.5 mt-2 shrink-0 text-muted-foreground/40" />
|
||||||
)}
|
)}
|
||||||
|
</Button>
|
||||||
<AvatarCircle avatar={u.avatar} active={u.active} />
|
<AvatarCircle avatar={u.avatar} active={u.active} />
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate">{u.name}</p>
|
<p className="text-sm font-medium truncate">{u.name}</p>
|
||||||
@@ -137,7 +139,7 @@ function UsersDataGrid({
|
|||||||
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
header: ({ column }) => <DataGridSortHeader column={column} title="Интерфейсы" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
|
||||||
<CableIcon className="size-3.5" />
|
<CableIcon aria-hidden="true" className="size-3.5" />
|
||||||
{row.original.bindings.length}
|
{row.original.bindings.length}
|
||||||
</span>
|
</span>
|
||||||
),
|
),
|
||||||
@@ -182,14 +184,14 @@ function UsersDataGrid({
|
|||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-7 p-0 text-muted-foreground hover:text-foreground"
|
className="size-11 md:size-8 p-0 text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => onEdit(u)}
|
onClick={() => onEdit(u)}
|
||||||
type="button"
|
type="button"
|
||||||
disabled={actionsDisabled}
|
disabled={actionsDisabled}
|
||||||
aria-label={"Редактировать пользователя " + u.name}
|
aria-label={"Редактировать пользователя " + u.name}
|
||||||
title="Редактировать"
|
title="Редактировать"
|
||||||
>
|
>
|
||||||
<PencilIcon className="size-3.5" />
|
<PencilIcon aria-hidden="true" className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -201,7 +203,7 @@ function UsersDataGrid({
|
|||||||
aria-label={"Удалить пользователя " + u.name}
|
aria-label={"Удалить пользователя " + u.name}
|
||||||
title="Удалить"
|
title="Удалить"
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-3.5" />
|
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
@@ -228,7 +230,7 @@ function UsersDataGrid({
|
|||||||
if (!isLoading && users.length === 0) {
|
if (!isLoading && users.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<UsersIcon className="size-4" />}
|
icon={<UsersIcon aria-hidden="true" className="size-4" />}
|
||||||
title="Нет пользователей"
|
title="Нет пользователей"
|
||||||
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
|
description="Добавьте пользователя, чтобы привязать интерфейсы и учитывать трафик"
|
||||||
className="border-0 py-12"
|
className="border-0 py-12"
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
import { useEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react"
|
||||||
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
import { FormField, FormToggle, SegmentedControl } from "@/components/form-kit"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from "@/components/ui/input"
|
||||||
@@ -79,28 +79,31 @@ function pickKey(p: CatalogPick): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
|
const SECTION_GROUP_ICONS: Record<string, ReactNode> = {
|
||||||
"Обзор": <LayoutDashboardIcon className="size-3" />,
|
"Обзор": <LayoutDashboardIcon aria-hidden="true" className="size-3" />,
|
||||||
"Данные": <EyeIcon className="size-3" />,
|
"Данные": <EyeIcon aria-hidden="true" className="size-3" />,
|
||||||
"Управление": <WrenchIcon className="size-3" />,
|
"Управление": <WrenchIcon aria-hidden="true" className="size-3" />,
|
||||||
"Инструменты": <ShieldIcon className="size-3" />,
|
"Инструменты": <ShieldIcon aria-hidden="true" className="size-3" />,
|
||||||
"Система": <ServerIcon className="size-3" />,
|
"Система": <ServerIcon aria-hidden="true" className="size-3" />,
|
||||||
}
|
}
|
||||||
|
|
||||||
function PermPills({ value, onChange, disabled }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean }) {
|
function PermPills({ value, onChange, disabled, label }: { value: PermLevel; onChange: (v: PermLevel) => void; disabled?: boolean; label: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn("flex rounded border border-input overflow-hidden h-6", disabled && "opacity-40 pointer-events-none")}>
|
<div role="group" aria-label={"Права: " + label} className={cn("flex shrink-0 rounded border border-input overflow-hidden min-h-8", disabled && "opacity-40")}>
|
||||||
{PERM_OPTS.map((o, i) => (
|
{PERM_OPTS.map((o, i) => (
|
||||||
<button
|
<button
|
||||||
key={o.v}
|
key={o.v}
|
||||||
type="button"
|
type="button"
|
||||||
|
disabled={disabled}
|
||||||
|
aria-label={label + ": " + o.label}
|
||||||
|
aria-pressed={value === o.v}
|
||||||
onClick={() => onChange(o.v)}
|
onClick={() => onChange(o.v)}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 text-[11px] font-medium transition-colors",
|
"px-2 text-[11px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring",
|
||||||
i < PERM_OPTS.length - 1 && "border-r border-input",
|
i < PERM_OPTS.length - 1 && "border-r border-input",
|
||||||
value === o.v
|
value === o.v
|
||||||
? o.v === "write" ? "bg-success text-white"
|
? o.v === "write" ? "bg-success/15 text-success"
|
||||||
: o.v === "read" ? "bg-info text-white"
|
: o.v === "read" ? "bg-info/15 text-info"
|
||||||
: "bg-muted-foreground/70 text-white"
|
: "bg-muted text-foreground"
|
||||||
: "text-muted-foreground hover:bg-muted",
|
: "text-muted-foreground hover:bg-muted",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -131,10 +134,13 @@ function toForm(user: AppUser): AppUserForm {
|
|||||||
|
|
||||||
function isValidEmail(value: string) {
|
function isValidEmail(value: string) {
|
||||||
const email = value.trim()
|
const email = value.trim()
|
||||||
return email.length > 0 && email.includes("@")
|
const input = document.createElement("input")
|
||||||
|
input.type = "email"; input.value = email
|
||||||
|
return email.length > 0 && input.checkValidity()
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UserSheetProps {
|
interface UserSheetProps {
|
||||||
|
returnFocus?: RefObject<HTMLElement | null>
|
||||||
open: boolean
|
open: boolean
|
||||||
user: AppUser | null
|
user: AppUser | null
|
||||||
users: AppUser[]
|
users: AppUser[]
|
||||||
@@ -151,8 +157,10 @@ interface UserSheetProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function UserSheet({
|
function UserSheet({
|
||||||
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose,
|
open, user, users, servers, isLive, backendUrl, saving, saveError, saveBlocked, saveResume, onCheckCatalogs, onSave, onClose, returnFocus,
|
||||||
}: UserSheetProps) {
|
}: UserSheetProps) {
|
||||||
|
const saveErrorRef = useRef<HTMLDivElement>(null)
|
||||||
|
useEffect(() => { if (saveError) saveErrorRef.current?.scrollIntoView({ block: "nearest" }) }, [saveError])
|
||||||
const isCreate = user === null
|
const isCreate = user === null
|
||||||
const [sheetStep, setSheetStep] = useState(1)
|
const [sheetStep, setSheetStep] = useState(1)
|
||||||
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
|
const [form, setForm] = useState<AppUserForm>(() => (user ? toForm(user) : emptyForm(servers)))
|
||||||
@@ -203,23 +211,23 @@ function UserSheet({
|
|||||||
const setSectionPerm = (section: string, level: PermLevel) => {
|
const setSectionPerm = (section: string, level: PermLevel) => {
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
sections: f.sections.map((s) => s.section === section ? { ...s, level } : s),
|
sections: [...f.sections.filter((s) => s.section !== section), { section, level }],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const setServerPerm = (serverId: string, level: PermLevel) => {
|
const setServerPerm = (serverId: string, level: PermLevel) => {
|
||||||
setForm((f) => ({
|
setForm((f) => ({
|
||||||
...f,
|
...f,
|
||||||
servers: f.servers.map((s) => s.serverId === serverId ? { ...s, level } : s),
|
servers: [...f.servers.filter((s) => s.serverId !== serverId), { serverId, level }],
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
const setAllSections = (level: PermLevel) => {
|
const setAllSections = (level: PermLevel) => {
|
||||||
setForm((f) => ({ ...f, sections: f.sections.map((s) => ({ ...s, level })) }))
|
setForm((f) => ({ ...f, sections: SECTION_GROUP_DEFS.flatMap((group) => group.items.map((section) => ({ section, level }))) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const setAllServers = (level: PermLevel) => {
|
const setAllServers = (level: PermLevel) => {
|
||||||
setForm((f) => ({ ...f, servers: f.servers.map((s) => ({ ...s, level })) }))
|
setForm((f) => ({ ...f, servers: servers.map((server) => ({ serverId: server.id, level })) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
const submit = () => {
|
const submit = () => {
|
||||||
@@ -230,6 +238,7 @@ function UserSheet({
|
|||||||
setErrors(e)
|
setErrors(e)
|
||||||
if (Object.keys(e).length) {
|
if (Object.keys(e).length) {
|
||||||
setSheetStep(1)
|
setSheetStep(1)
|
||||||
|
requestAnimationFrame(() => document.getElementById(e.name ? "user-full-name" : "user-email")?.focus())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
onSave({ ...form, email, login: email })
|
onSave({ ...form, email, login: email })
|
||||||
@@ -303,7 +312,7 @@ function UserSheet({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
|
<Sheet open={open} onOpenChange={(v) => { if (!v && !saving) onClose() }}>
|
||||||
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col gap-0 p-0">
|
<SheetContent finalFocus={returnFocus} side="right" className="data-[side=right]:w-full data-[side=right]:sm:max-w-lg min-w-0 flex flex-col gap-0 p-0">
|
||||||
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
<SheetHeader className="px-6 pt-6 pb-4 border-b shrink-0">
|
||||||
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
|
<SheetTitle>{isCreate ? "Новый пользователь" : "Редактировать пользователя"}</SheetTitle>
|
||||||
<SheetDescription>
|
<SheetDescription>
|
||||||
@@ -311,14 +320,13 @@ function UserSheet({
|
|||||||
</SheetDescription>
|
</SheetDescription>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
|
|
||||||
{saveError && <div role="alert" aria-label="Сохранение пользователя" className="px-6 py-3 text-sm text-destructive">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
|
<fieldset disabled={saving} className="flex-1 min-h-0 min-w-0 flex flex-col">
|
||||||
<fieldset disabled={saving} className="flex-1 min-h-0 flex flex-col">
|
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 min-w-0 px-4 sm:px-6 py-5">
|
||||||
<Stepper value={sheetStep} onValueChange={setSheetStep} className="flex-1 flex flex-col min-h-0 px-6 py-5">
|
<StepperNav className="mb-3">
|
||||||
<StepperNav className="mb-5">
|
|
||||||
<StepperItem step={1}>
|
<StepperItem step={1}>
|
||||||
<StepperTrigger>
|
<StepperTrigger>
|
||||||
<StepperIndicator>1</StepperIndicator>
|
<StepperIndicator>1</StepperIndicator>
|
||||||
<StepperTitle className="sr-only">Профиль</StepperTitle>
|
<StepperTitle className="sr-only sm:not-sr-only">Профиль</StepperTitle>
|
||||||
</StepperTrigger>
|
</StepperTrigger>
|
||||||
<StepperSeparator />
|
<StepperSeparator />
|
||||||
</StepperItem>
|
</StepperItem>
|
||||||
@@ -343,21 +351,30 @@ function UserSheet({
|
|||||||
</StepperTrigger>
|
</StepperTrigger>
|
||||||
</StepperItem>
|
</StepperItem>
|
||||||
</StepperNav>
|
</StepperNav>
|
||||||
<StepperPanel className="flex-1 overflow-y-auto">
|
<p className="text-sm font-medium mb-3">{["Профиль", "Разделы", "Серверы", "Интерфейсы"][sheetStep - 1]} · {sheetStep}/4</p>
|
||||||
|
<StepperPanel className="flex-1 min-h-0 overflow-y-auto [overflow-wrap:anywhere]">
|
||||||
|
{saveError && <div role="alert" aria-label="Сохранение пользователя" ref={saveErrorRef} className="py-3 text-sm text-destructive [overflow-wrap:anywhere]">{saveError}{saveBlocked && <Button type="button" variant="outline" disabled={saving} onClick={onCheckCatalogs}>Проверить список пользователей</Button>}</div>}
|
||||||
<StepperContent value={1} className="flex flex-col gap-4">
|
<StepperContent value={1} className="flex flex-col gap-4">
|
||||||
<FormField label="Полное имя" required error={errors.name}>
|
<FormField label="Полное имя" required error={errors.name}>
|
||||||
<Input value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
|
<Input id="user-full-name" aria-label="Полное имя" aria-invalid={!!errors.name} aria-describedby={errors.name ? "user-name-error" : undefined} value={form.name} onChange={(e) => setField("name", e.target.value)} placeholder="Иван Иванов" />
|
||||||
|
{errors.name && <span id="user-name-error" className="sr-only">{errors.name}</span>}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Email" required error={errors.email}>
|
<FormField label="Email" required error={errors.email}>
|
||||||
<Input
|
<Input
|
||||||
|
id="user-email"
|
||||||
|
aria-label="Email"
|
||||||
|
aria-invalid={!!errors.email}
|
||||||
|
aria-describedby={errors.email ? "user-email-error" : undefined}
|
||||||
value={form.email}
|
value={form.email}
|
||||||
onChange={(e) => setField("email", e.target.value)}
|
onChange={(e) => setField("email", e.target.value)}
|
||||||
placeholder="i.ivanov@company.io"
|
placeholder="i.ivanov@company.io"
|
||||||
type="email"
|
type="email"
|
||||||
/>
|
/>
|
||||||
|
{errors.email && <span id="user-email-error" className="sr-only">{errors.email}</span>}
|
||||||
</FormField>
|
</FormField>
|
||||||
<FormField label="Роль" required>
|
<FormField label="Роль" required>
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
|
className="w-full flex-col items-stretch sm:flex-row [&>button]:whitespace-nowrap [&>button]:min-h-9"
|
||||||
value={form.role}
|
value={form.role}
|
||||||
onChange={setRole}
|
onChange={setRole}
|
||||||
options={[
|
options={[
|
||||||
@@ -368,8 +385,8 @@ function UserSheet({
|
|||||||
/>
|
/>
|
||||||
</FormField>
|
</FormField>
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-medium">Активна</span>
|
<span className="text-sm font-medium">Активный профиль</span>
|
||||||
<FormToggle checked={form.active} onChange={(v) => setField("active", v)} />
|
<FormToggle ariaLabel="Активный профиль" checked={form.active} onChange={(v) => setField("active", v)} />
|
||||||
</div>
|
</div>
|
||||||
</StepperContent>
|
</StepperContent>
|
||||||
|
|
||||||
@@ -393,7 +410,7 @@ function UserSheet({
|
|||||||
))}
|
))}
|
||||||
{isAdmin && (
|
{isAdmin && (
|
||||||
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
|
<span className="ml-auto text-[11px] text-muted-foreground flex items-center gap-1">
|
||||||
<ShieldIcon className="size-3" />Администратор имеет полный доступ
|
<ShieldIcon aria-hidden="true" className="size-3" />Администратор имеет полный доступ
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -410,9 +427,9 @@ function UserSheet({
|
|||||||
const perm = form.sections.find((s) => s.section === section)
|
const perm = form.sections.find((s) => s.section === section)
|
||||||
const level = perm?.level ?? "none"
|
const level = perm?.level ?? "none"
|
||||||
return (
|
return (
|
||||||
<div key={section} className="flex items-center justify-between py-2.5 hover:bg-muted/20 transition-colors">
|
<div key={section} className="flex flex-wrap items-center justify-between gap-2 py-2.5 hover:bg-muted/20 transition-colors">
|
||||||
<span className="text-sm">{section}</span>
|
<span className="text-sm">{section}</span>
|
||||||
<PermPills value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
|
<PermPills label={section} value={level} onChange={(v) => setSectionPerm(section, v)} disabled={isAdmin} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -445,16 +462,16 @@ function UserSheet({
|
|||||||
const perm = form.servers.find((s) => s.serverId === srv.id)
|
const perm = form.servers.find((s) => s.serverId === srv.id)
|
||||||
const level = perm?.level ?? "none"
|
const level = perm?.level ?? "none"
|
||||||
return (
|
return (
|
||||||
<div key={srv.id} className="flex items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
|
<div key={srv.id} className="flex flex-wrap items-center justify-between gap-3 py-2.5 hover:bg-muted/20 transition-colors">
|
||||||
<div className="flex items-center gap-2.5 min-w-0">
|
<div className="flex flex-1 items-center gap-2.5 min-w-0">
|
||||||
{srv.status ? <StatusDot status={srv.status} /> : null}
|
{srv.status ? <StatusDot status={srv.status} /> : null}
|
||||||
{srv.country ? <Flag code={srv.country} size={14} /> : null}
|
{srv.country ? <Flag code={srv.country} size={14} /> : null}
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
|
<p className="text-sm font-medium truncate leading-tight">{srv.name}</p>
|
||||||
<p className="text-[11px] font-mono text-muted-foreground">{srv.host} · {srv.site}</p>
|
<p className="text-[11px] font-mono text-muted-foreground truncate" title={srv.host}>{srv.host} · {srv.site}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<PermPills value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
|
<PermPills label={srv.name} value={level} onChange={(v) => setServerPerm(srv.id, v)} disabled={isAdmin} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@@ -509,7 +526,7 @@ function UserSheet({
|
|||||||
<Item
|
<Item
|
||||||
size="xs"
|
size="xs"
|
||||||
variant={open ? "muted" : "default"}
|
variant={open ? "muted" : "default"}
|
||||||
render={<button type="button" onClick={() => setExpandedWg(open ? null : iface.name)} />}
|
render={<button type="button" aria-expanded={open} onClick={() => setExpandedWg(open ? null : iface.name)} />}
|
||||||
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
|
className="h-11 min-h-11 flex-nowrap rounded-md py-0"
|
||||||
>
|
>
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
@@ -527,7 +544,7 @@ function UserSheet({
|
|||||||
{legacyMine ? (
|
{legacyMine ? (
|
||||||
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
|
<Badge variant="warning-light" size="xs">весь интерфейс</Badge>
|
||||||
) : null}
|
) : null}
|
||||||
<ChevronDownIcon className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
<ChevronDownIcon aria-hidden="true" className={cn("size-3.5 text-muted-foreground transition-transform", open && "rotate-180")} />
|
||||||
</ItemActions>
|
</ItemActions>
|
||||||
</Item>
|
</Item>
|
||||||
{open ? (
|
{open ? (
|
||||||
@@ -558,6 +575,7 @@ function UserSheet({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
onClick={() => togglePick(pick)}
|
onClick={() => togglePick(pick)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -569,7 +587,7 @@ function UserSheet({
|
|||||||
>
|
>
|
||||||
<ItemMedia>
|
<ItemMedia>
|
||||||
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
|
<IconTile variant="elevated" className="size-10.5 shrink-0 text-success">
|
||||||
<KeyRoundIcon />
|
<KeyRoundIcon aria-hidden="true" />
|
||||||
</IconTile>
|
</IconTile>
|
||||||
</ItemMedia>
|
</ItemMedia>
|
||||||
<ItemContent className="min-w-0">
|
<ItemContent className="min-w-0">
|
||||||
@@ -612,6 +630,7 @@ function UserSheet({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
|
aria-pressed={selected}
|
||||||
onClick={() => togglePick(pick)}
|
onClick={() => togglePick(pick)}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
@@ -652,12 +671,13 @@ function UserSheet({
|
|||||||
</Frame>
|
</Frame>
|
||||||
<Input
|
<Input
|
||||||
className="h-8 text-xs"
|
className="h-8 text-xs"
|
||||||
|
aria-label="Комментарий привязки"
|
||||||
placeholder="Комментарий (необязательно)"
|
placeholder="Комментарий (необязательно)"
|
||||||
value={newComment}
|
value={newComment}
|
||||||
onChange={(e) => setNewComment(e.target.value)}
|
onChange={(e) => setNewComment(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
|
<Button size="sm" disabled={!catalogReady || validSelectedPicks.length === 0} onClick={addSelectedBindings}>
|
||||||
<PlusIcon className="size-3.5" />Привязать
|
<PlusIcon aria-hidden="true" className="size-3.5" />Привязать
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -678,9 +698,11 @@ function UserSheet({
|
|||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
|
className="size-7 p-0 ml-auto text-muted-foreground hover:text-destructive"
|
||||||
|
type="button"
|
||||||
|
aria-label={"Удалить привязку " + bindingTitle(b) + " · " + b.serverName}
|
||||||
onClick={() => removeBinding(b.id)}
|
onClick={() => removeBinding(b.id)}
|
||||||
>
|
>
|
||||||
<TrashIcon className="size-3.5" />
|
<TrashIcon aria-hidden="true" className="size-3.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -689,7 +711,7 @@ function UserSheet({
|
|||||||
</StepperPanel>
|
</StepperPanel>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
|
|
||||||
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row gap-2">
|
<SheetFooter className="px-6 py-4 border-t shrink-0 flex-row flex-wrap gap-2">
|
||||||
<SheetClose render={<Button type="button" variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
<SheetClose render={<Button type="button" variant="outline" disabled={saving} />}>Отмена</SheetClose>
|
||||||
{sheetStep > 1 && (
|
{sheetStep > 1 && (
|
||||||
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
<Button variant="outline" onClick={() => setSheetStep((s) => s - 1)}>Назад</Button>
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
| 8 | Трафик и статистика | Завершён 2026-09-28 в границах локальной приёмки; ограничения в блоке 8.13 |
|
||||||
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
| 9 | VPN, Firewall и Filters | Завершён в границах локальной приёмки 2026-09-30; блоки 9.1–9.64, ограничения в 9.64 |
|
||||||
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
| 10 | Маршрутизация, диагностика, карта и терминал | Завершён в границах локальной приёмки 2026-10-01; блоки 10.1–10.71, внешние ограничения в 10.71 |
|
||||||
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.7; state каталог подключён; UX/приёмка пользователей далее |
|
| 11А | Пользователи, контейнеры, сертификаты и резервные копии | В работе: 11А.1–11А.8; пользователи приняты локально, далее контейнеры |
|
||||||
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
| 11Б | Оповещения, сбор данных, интеграции, релизы и настройки | Не начат |
|
||||||
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
| 12 | Итоговая доступность, адаптивность, производительность и регрессия | Не начат |
|
||||||
|
|
||||||
@@ -36,6 +36,15 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
|
||||||
|
|
||||||
|
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
|
||||||
|
- Sheet занимает ширину телефона; min-w-0 fieldset удерживает шаги в 320 px, выбор роли переносится вертикально без разрыва слов. Ошибка сохранения внутри области прокрутки, footer остаётся доступен; обязательные поля имеют aria-invalid/describedby и получают фокус.
|
||||||
|
- Закрытие формы возвращает фокус к актуальной кнопке строки после её замены Data Grid. Найден и исправлен исходный no-op прав: отсутствующие section/server разрешения добавляются при явном выборе; «выбрать всё» охватывает показанные разделы/серверы.
|
||||||
|
- 48 уникальных data/operations/catalog/layout E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. HTTP payload проверен для отсутствующих разрешений. 12 снимков ПК/390/320 сохранены; просмотрены обзор/профиль/каталог/ошибка, горизонтальная прокрутка ограничена таблицей.
|
||||||
|
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
|
||||||
|
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
|
||||||
|
|
||||||
## Блок 11А.7 — frontend state каталог (2026-10-01)
|
## Блок 11А.7 — frontend state каталог (2026-10-01)
|
||||||
|
|
||||||
- UserSheet использует /interface-catalog-state, строго проверяет новый контракт и точный serverId. Legacy reader экспортирован для совместимости, новый экран к неоднозначному envelope не возвращается.
|
- UserSheet использует /interface-catalog-state, строго проверяет новый контракт и точный serverId. Legacy reader экспортирован для совместимости, новый экран к неоднозначному envelope не возвращается.
|
||||||
|
|||||||
@@ -96,3 +96,13 @@
|
|||||||
- Частичные failures интерфейсов/WG/IPsec видны сразу с повтором, включая пустой IPsec failure. IPsec-vpn подписан как каталог клиентов, а не физический интерфейс. Демо и live состояния сохраняют прежнюю композицию Frame/Stepper.
|
- Частичные failures интерфейсов/WG/IPsec видны сразу с повтором, включая пустой IPsec failure. IPsec-vpn подписан как каталог клиентов, а не физический интерфейс. Демо и live состояния сохраняют прежнюю композицию Frame/Stepper.
|
||||||
- 44 различных data/operations/catalog E2E + 8 context E2E, TypeScript, профильный lint и production frontend прошли. Фикстуры перенесены на новый state envelope; отдельные negative тесты не дополняют отсутствующие метаданные автоматически.
|
- 44 различных data/operations/catalog E2E + 8 context E2E, TypeScript, профильный lint и production frontend прошли. Фикстуры перенесены на новый state envelope; отдельные negative тесты не дополняют отсутствующие метаданные автоматически.
|
||||||
- Далее 11А.8: семантические токены таблицы/формы, доступные имена, права/клавиатура, ПК/390/320 и снимки. После приёмки пользователей — контейнеры. Реальная БД/RouterOS/Portal остаётся внешней приёмкой.
|
- Далее 11А.8: семантические токены таблицы/формы, доступные имена, права/клавиатура, ПК/390/320 и снимки. После приёмки пользователей — контейнеры. Реальная БД/RouterOS/Portal остаётся внешней приёмкой.
|
||||||
|
|
||||||
|
## Блок 11А.8 — доступность и локальная приёмка пользователей (2026-10-01)
|
||||||
|
|
||||||
|
- Существующие ReUI Stepper/Data Grid/Frame сохранены. Семантические токены, явные имена действий/прав, aria-pressed/expanded, клавиатурное раскрытие строки, подпись текущего шага. Vendor компоненты и тема не изменены; checklist ReUI проверен.
|
||||||
|
- Sheet занимает ширину телефона; min-w-0 fieldset удерживает шаги в 320 px, выбор роли переносится вертикально без разрыва слов. Ошибка сохранения внутри области прокрутки, footer остаётся доступен; обязательные поля имеют aria-invalid/describedby и получают фокус.
|
||||||
|
- Закрытие формы возвращает фокус к актуальной кнопке строки после её замены Data Grid. Найден и исправлен исходный no-op прав: отсутствующие section/server разрешения добавляются при явном выборе; «выбрать всё» охватывает показанные разделы/серверы.
|
||||||
|
- 48 уникальных data/operations/catalog/layout E2E, 8 context E2E, TypeScript, профильный lint и production frontend прошли. HTTP payload проверен для отсутствующих разрешений. 12 снимков ПК/390/320 сохранены; просмотрены обзор/профиль/каталог/ошибка, горизонтальная прокрутка ограничена таблицей.
|
||||||
|
- Пользователи приняты в границах локальных HTTP-подмен. Реальные Portal/PostgreSQL/RouterOS остаются внешней приёмкой; прежние DB binding тесты skipped.
|
||||||
|
- Далее 11А.9: достоверный state API контейнеров. Аудит выявил превращение отказов устройств/envs/mounts в пустые списки, неизвестного статуса в stopped и недостоверное общее empty. Затем подключение frontend, операции и UX; сертификаты/backup ещё не приняты.
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 167 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -92,8 +92,8 @@ export const ROLE_LABEL: Record<Role, string> = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ROLE_COLOR: Record<Role, string> = {
|
export const ROLE_COLOR: Record<Role, string> = {
|
||||||
admin: "bg-violet-500/10 text-violet-600 dark:text-violet-400 border border-violet-500/20",
|
admin: "bg-primary/10 text-primary border border-primary/20",
|
||||||
operator: "bg-sky-500/10 text-sky-600 dark:text-sky-400 border border-sky-500/20",
|
operator: "bg-info/10 text-info border border-info/20",
|
||||||
viewer: "bg-muted text-muted-foreground border border-border",
|
viewer: "bg-muted text-muted-foreground border border-border",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -105,7 +105,7 @@ export const PERM_OPTS: { v: PermLevel; label: string }[] = [
|
|||||||
|
|
||||||
export const PERM_COLOR: Record<PermLevel, string> = {
|
export const PERM_COLOR: Record<PermLevel, string> = {
|
||||||
none: "bg-muted text-muted-foreground",
|
none: "bg-muted text-muted-foreground",
|
||||||
read: "bg-sky-500/10 text-sky-600 dark:text-sky-400",
|
read: "bg-info/10 text-info",
|
||||||
write: "bg-success/10 text-success",
|
write: "bg-success/10 text-success",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {test,expect} from "@playwright/test"
|
||||||
|
import {user,server,catalogResponse} from "./fixtures/users"
|
||||||
|
for(const [name,width] of [["desktop",1440],["mobile",390],["narrow",320]] as const)test("Пользователи: клавиатура и адаптивность "+name,async({page})=>{
|
||||||
|
await page.setViewportSize({width,height:900});const longUser={...user,name:"Очень длинное имя пользователя для проверки таблицы и формы",email:"long-user-name@example.test",login:"long-user-name@example.test"}
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>{const p=new URL(r.request().url()).pathname;return catalogResponse(r,{json:p==="/api/auth/config"?{required:false}:p==="/api/users"?{users:[longUser]}:p==="/api/servers"?[{...server,name:"Очень длинное имя устройства для проверки права доступа",host:"router-with-long-hostname.example.test"}]:{interfaces:[]}})})
|
||||||
|
let saved: {sections: {section:string;level:string}[];servers:{serverId:string;level:string}[]} | undefined
|
||||||
|
await page.route("**/api/users/u-test",r=>{saved=r.request().postDataJSON();return r.fulfill({status:503,json:{error:"Профиль не сохранён. Повторите позже."}})})
|
||||||
|
await page.goto("/users");await expect(page.getByRole("button",{name:"Добавить пользователя",exact:true})).toBeEnabled();expect(await page.evaluate(()=>document.documentElement.scrollWidth<=window.innerWidth)).toBe(true)
|
||||||
|
const expand=page.getByRole("button",{name:"Доступ и привязки пользователя "+longUser.name});await expand.focus();await page.keyboard.press("Enter");await expect(expand).toHaveAttribute("aria-expanded","true");await expect(page.getByText("Нет привязанных интерфейсов",{exact:true})).toBeVisible()
|
||||||
|
await page.screenshot({path:"docs/ux-ui/screens/users-overview-"+name+".png",fullPage:true})
|
||||||
|
const edit=page.getByRole("button",{name:"Редактировать пользователя "+longUser.name});await edit.focus();await page.keyboard.press("Enter");const dialog=page.getByRole("dialog");await expect(page.getByRole("textbox",{name:"Полное имя",exact:true})).toHaveValue(longUser.name);await expect(page.getByRole("textbox",{name:"Email",exact:true})).toHaveValue(longUser.email)
|
||||||
|
const bounds=await dialog.boundingBox();expect(bounds&&bounds.x>=0&&bounds.x+bounds.width<=width+1).toBe(true);expect(width>=640||bounds&&bounds.width>=width-2).toBe(true)
|
||||||
|
await page.screenshot({path:"docs/ux-ui/screens/users-profile-"+name+".png",fullPage:true})
|
||||||
|
const sections=dialog.getByRole("tab",{name:/Разделы/});await sections.focus();await page.keyboard.press("Enter");await expect(sections).toHaveAttribute("aria-selected","true")
|
||||||
|
const permission=dialog.getByRole("button",{name:"Дашборд: Управление",exact:true});await permission.focus();await page.keyboard.press("Space");await expect(permission).toHaveAttribute("aria-pressed","true")
|
||||||
|
await dialog.getByRole("tab",{name:/Серверы/}).click();await expect(dialog.getByRole("group",{name:"Права: Очень длинное имя устройства для проверки права доступа"})).toBeVisible();await dialog.getByRole("button",{name:"Очень длинное имя устройства для проверки права доступа: Управление",exact:true}).click();await dialog.getByRole("tab",{name:/Интерфейсы/}).click();await expect(dialog.getByText("Нет интерфейсов в каталоге",{exact:true})).toBeVisible()
|
||||||
|
await page.screenshot({path:"docs/ux-ui/screens/users-catalog-"+name+".png",fullPage:true})
|
||||||
|
await dialog.getByRole("button",{name:"Сохранить",exact:true}).click();await expect(dialog.getByRole("alert",{name:"Сохранение пользователя"})).toContainText("Профиль не сохранён");expect(saved?.sections).toContainEqual({section:"Дашборд",level:"write"});expect(saved?.servers).toContainEqual({serverId:"1",level:"write"});const footer=dialog.getByRole("button",{name:"Сохранить",exact:true});const box=await footer.boundingBox();expect(box&&box.y>=0&&box.y+box.height<=900).toBe(true);expect(await dialog.evaluate(el=>el.scrollWidth<=el.clientWidth+1)).toBe(true)
|
||||||
|
await page.screenshot({path:"docs/ux-ui/screens/users-error-"+name+".png",fullPage:true})
|
||||||
|
await dialog.getByRole("button",{name:"Отмена",exact:true}).click();await expect(dialog).toHaveCount(0);await expect(edit).toBeFocused()
|
||||||
|
})
|
||||||
|
test("Пользователи: ошибки обязательных полей и фокус",async({page})=>{
|
||||||
|
await page.route("**/health",r=>r.fulfill({json:{ok:true}}));await page.route("**/api/**",r=>catalogResponse(r,{json:new URL(r.request().url()).pathname==="/api/auth/config"?{required:false}:new URL(r.request().url()).pathname==="/api/users"?{users:[user]}:new URL(r.request().url()).pathname==="/api/servers"?[server]:{interfaces:[]}}))
|
||||||
|
await page.goto("/users");await page.getByRole("button",{name:"Добавить пользователя",exact:true}).click();const dialog=page.getByRole("dialog");await dialog.getByRole("tab",{name:/Интерфейсы/}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();const name=dialog.getByRole("textbox",{name:"Полное имя",exact:true});await expect(name).toBeFocused();await expect(name).toHaveAttribute("aria-invalid","true");await name.fill("Тест");await dialog.getByRole("textbox",{name:"Email",exact:true}).fill("bad@");await dialog.getByRole("tab",{name:/Интерфейсы/}).click();await dialog.getByRole("button",{name:"Создать",exact:true}).click();const email=dialog.getByRole("textbox",{name:"Email",exact:true});await expect(email).toBeFocused();await expect(email).toHaveAttribute("aria-invalid","true")
|
||||||
|
})
|
||||||