feat(web): DomainsPage — список, импорт зон, привязка шаблона
This commit is contained in:
+2
-1
@@ -1,6 +1,7 @@
|
||||
import { Routes, Route, Navigate } from "react-router-dom"
|
||||
import { Layout } from "@/components/Layout"
|
||||
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||
import { DomainsPage } from "@/pages/DomainsPage"
|
||||
|
||||
function Placeholder({ name }: { name: string }) {
|
||||
return <div className="p-8 text-2xl">{name}</div>
|
||||
@@ -11,7 +12,7 @@ export function App() {
|
||||
<Layout>
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/domains" replace />} />
|
||||
<Route path="/domains" element={<Placeholder name="Domains" />} />
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
||||
<Route path="/accounts" element={<Placeholder name="Accounts" />} />
|
||||
<Route path="/templates" element={<Placeholder name="Templates" />} />
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { render, screen, waitFor } from "@testing-library/react"
|
||||
import userEvent from "@testing-library/user-event"
|
||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||
import { DomainsPage } from "./DomainsPage"
|
||||
import { api } from "@/api/client"
|
||||
import { vi, beforeEach, test, expect } from "vitest"
|
||||
import type { Account, Domain, Template } from "@/api/types"
|
||||
|
||||
const accounts: Account[] = [
|
||||
{ id: "acc1", provider: "selectel", comment: "Main" },
|
||||
{ id: "acc2", provider: "cloudflare", comment: "Backup" },
|
||||
]
|
||||
const templates: Template[] = [
|
||||
{ id: "t1", name: "Standard", records: [], version: 1 },
|
||||
{ id: "t2", name: "Minimal", records: [], version: 1 },
|
||||
]
|
||||
const domains: Domain[] = [
|
||||
{ id: "d1", providerAccountId: "acc1", zoneName: "example.com.", zoneId: "z1", templateId: null },
|
||||
{ id: "d2", providerAccountId: "acc2", zoneName: "test.org.", zoneId: "z2", templateId: "t1" },
|
||||
]
|
||||
|
||||
function renderPage() {
|
||||
const qc = new QueryClient()
|
||||
return render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<MemoryRouter initialEntries={["/domains"]}>
|
||||
<Routes>
|
||||
<Route path="/domains" element={<DomainsPage />} />
|
||||
<Route path="/domains/:id" element={<div>diff page</div>} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue(domains)
|
||||
vi.spyOn(api, "listAccounts").mockResolvedValue(accounts)
|
||||
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
|
||||
})
|
||||
|
||||
test("отрисовывает домены и ссылку на diff-страницу", async () => {
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText("example.com.")).toBeInTheDocument()
|
||||
expect(screen.getByText("test.org.")).toBeInTheDocument()
|
||||
|
||||
const links = screen.getAllByRole("link", { name: /diff/i })
|
||||
expect(links.length).toBe(2)
|
||||
expect(links[0]).toHaveAttribute("href", "/domains/d1")
|
||||
expect(links[1]).toHaveAttribute("href", "/domains/d2")
|
||||
})
|
||||
|
||||
test("кнопка импорта вызывает api.importZones с выбранной учёткой", async () => {
|
||||
const importSpy = vi.spyOn(api, "importZones").mockResolvedValue([])
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /учётн/i }))
|
||||
await user.click(await screen.findByRole("option", { name: /cloudflare/i }))
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /импортировать зоны/i }))
|
||||
|
||||
await waitFor(() => expect(importSpy).toHaveBeenCalledWith("acc2"))
|
||||
})
|
||||
|
||||
test("привязка шаблона в строке домена вызывает api.setDomainTemplate", async () => {
|
||||
const setTemplateSpy = vi.spyOn(api, "setDomainTemplate").mockResolvedValue(domains[0])
|
||||
const user = userEvent.setup()
|
||||
renderPage()
|
||||
|
||||
await screen.findByText("example.com.")
|
||||
|
||||
await user.click(screen.getByRole("combobox", { name: /example\.com\./i }))
|
||||
await user.click(await screen.findByRole("option", { name: /^standard$/i }))
|
||||
|
||||
await waitFor(() => expect(setTemplateSpy).toHaveBeenCalledWith("d1", "t1"))
|
||||
})
|
||||
|
||||
test("пустое состояние при отсутствии доменов", async () => {
|
||||
vi.spyOn(api, "listDomains").mockResolvedValue([])
|
||||
renderPage()
|
||||
|
||||
expect(await screen.findByText(/доменов пока нет/i)).toBeInTheDocument()
|
||||
})
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState } from "react"
|
||||
import { Link } from "react-router-dom"
|
||||
import { Inbox, Loader2, Trash2, Upload } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
useAccounts,
|
||||
useDeleteDomain,
|
||||
useDomains,
|
||||
useImportZones,
|
||||
useSetDomainTemplate,
|
||||
useTemplates,
|
||||
} from "@/hooks/useApi"
|
||||
|
||||
const NO_TEMPLATE = "__none__"
|
||||
|
||||
export function DomainsPage() {
|
||||
const domains = useDomains()
|
||||
const accounts = useAccounts()
|
||||
const templates = useTemplates()
|
||||
const importZones = useImportZones()
|
||||
const setTemplate = useSetDomainTemplate()
|
||||
const deleteDomain = useDeleteDomain()
|
||||
|
||||
const accountList = accounts.data ?? []
|
||||
const templateList = templates.data ?? []
|
||||
const domainList = domains.data ?? []
|
||||
|
||||
const [importAccountId, setImportAccountId] = useState<string | null>(null)
|
||||
const selectedImportAccount = importAccountId ?? accountList[0]?.id ?? null
|
||||
|
||||
const accountItems = accountList.map((a) => ({
|
||||
value: a.id,
|
||||
label: `${a.provider} · ${a.comment}`,
|
||||
}))
|
||||
|
||||
function accountLabel(id: string) {
|
||||
const acc = accountList.find((a) => a.id === id)
|
||||
return acc ? `${acc.provider} · ${acc.comment}` : id
|
||||
}
|
||||
|
||||
function onImport() {
|
||||
if (!selectedImportAccount) return
|
||||
importZones.mutate(selectedImportAccount)
|
||||
}
|
||||
|
||||
function onTemplateChange(domainId: string, value: unknown) {
|
||||
const templateId = value === NO_TEMPLATE ? null : (value as string)
|
||||
setTemplate.mutate({ id: domainId, templateId })
|
||||
}
|
||||
|
||||
function onDelete(domainId: string, zoneName: string) {
|
||||
if (window.confirm(`Удалить домен ${zoneName}? Действие необратимо.`)) {
|
||||
deleteDomain.mutate(domainId)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
|
||||
<header className="flex flex-col gap-1">
|
||||
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
|
||||
zones
|
||||
</span>
|
||||
<h1 className="text-xl font-semibold tracking-tight text-foreground">Domains</h1>
|
||||
</header>
|
||||
|
||||
<div className="flex flex-wrap items-end gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs font-medium text-muted-foreground">Учётная запись</span>
|
||||
<Select
|
||||
items={accountItems}
|
||||
value={selectedImportAccount}
|
||||
onValueChange={(v) => setImportAccountId(v as string)}
|
||||
>
|
||||
<SelectTrigger aria-label="Учётная запись для импорта" className="min-w-56">
|
||||
<SelectValue placeholder="Выберите учётку" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button onClick={onImport} disabled={!selectedImportAccount || importZones.isPending}>
|
||||
{importZones.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
<Upload className="size-4" strokeWidth={1.75} />
|
||||
)}
|
||||
Импортировать зоны
|
||||
</Button>
|
||||
{importZones.isError && (
|
||||
<span className="font-dns text-xs text-destructive">{importZones.error.message}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{domainList.length === 0 ? (
|
||||
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
|
||||
<Inbox className="size-6" strokeWidth={1.5} />
|
||||
Доменов пока нет — импортируйте зоны из учётной записи.
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Zone</TableHead>
|
||||
<TableHead>Учётка</TableHead>
|
||||
<TableHead>Шаблон</TableHead>
|
||||
<TableHead className="text-right">Действия</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domainList.map((d) => {
|
||||
const templateItems = [
|
||||
{ value: NO_TEMPLATE, label: "Без шаблона" },
|
||||
...templateList.map((t) => ({ value: t.id, label: t.name })),
|
||||
]
|
||||
return (
|
||||
<TableRow key={d.id}>
|
||||
<TableCell className="font-dns">{d.zoneName}</TableCell>
|
||||
<TableCell className="font-dns text-xs text-muted-foreground">
|
||||
{accountLabel(d.providerAccountId)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Select
|
||||
items={templateItems}
|
||||
value={d.templateId ?? NO_TEMPLATE}
|
||||
onValueChange={(v) => onTemplateChange(d.id, v)}
|
||||
>
|
||||
<SelectTrigger aria-label={`Шаблон: ${d.zoneName}`} size="sm" className="min-w-40">
|
||||
<SelectValue placeholder="Без шаблона" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{templateItems.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1.5">
|
||||
<Button variant="outline" size="sm" render={<Link to={`/domains/${d.id}`} />}>
|
||||
Diff
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
aria-label={`Удалить ${d.zoneName}`}
|
||||
onClick={() => onDelete(d.id, d.zoneName)}
|
||||
disabled={deleteDomain.isPending}
|
||||
>
|
||||
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user