From 0ce15d30a8068cd255ad9e2e485b268c10d45432 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 17:43:49 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20DomainsPage=20=E2=80=94=20=D1=81?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D0=BE=D0=BA,=20=D0=B8=D0=BC=D0=BF=D0=BE?= =?UTF-8?q?=D1=80=D1=82=20=D0=B7=D0=BE=D0=BD,=20=D0=BF=D1=80=D0=B8=D0=B2?= =?UTF-8?q?=D1=8F=D0=B7=D0=BA=D0=B0=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.tsx | 3 +- web/src/pages/DomainsPage.test.tsx | 88 ++++++++++++++ web/src/pages/DomainsPage.tsx | 183 +++++++++++++++++++++++++++++ 3 files changed, 273 insertions(+), 1 deletion(-) create mode 100644 web/src/pages/DomainsPage.test.tsx create mode 100644 web/src/pages/DomainsPage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index a34d878..a2c4e2a 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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
{name}
@@ -11,7 +12,7 @@ export function App() { } /> - } /> + } /> } /> } /> } /> diff --git a/web/src/pages/DomainsPage.test.tsx b/web/src/pages/DomainsPage.test.tsx new file mode 100644 index 0000000..2b2f402 --- /dev/null +++ b/web/src/pages/DomainsPage.test.tsx @@ -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( + + + + } /> + diff page} /> + + + , + ) +} + +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() +}) diff --git a/web/src/pages/DomainsPage.tsx b/web/src/pages/DomainsPage.tsx new file mode 100644 index 0000000..3542352 --- /dev/null +++ b/web/src/pages/DomainsPage.tsx @@ -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(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 ( +
+
+ + zones + +

Domains

+
+ +
+
+ Учётная запись + +
+ + {importZones.isError && ( + {importZones.error.message} + )} +
+ + {domainList.length === 0 ? ( +
+ + Доменов пока нет — импортируйте зоны из учётной записи. +
+ ) : ( + + + + Zone + Учётка + Шаблон + Действия + + + + {domainList.map((d) => { + const templateItems = [ + { value: NO_TEMPLATE, label: "Без шаблона" }, + ...templateList.map((t) => ({ value: t.id, label: t.name })), + ] + return ( + + {d.zoneName} + + {accountLabel(d.providerAccountId)} + + + + + +
+ + +
+
+
+ ) + })} +
+
+ )} +
+ ) +}