From 99e09d35fbcdf90f40c559345da3f5da1f38b649 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Fri, 3 Jul 2026 18:02:08 +0700 Subject: [PATCH] =?UTF-8?q?feat(web):=20TemplatesPage=20+=20RecordEditor?= =?UTF-8?q?=20=E2=80=94=20CRUD=20=D1=88=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=20=D1=81=20=D1=80=D0=B5=D0=B4=D0=B0=D0=BA=D1=82?= =?UTF-8?q?=D0=BE=D1=80=D0=BE=D0=BC=20=D0=B7=D0=B0=D0=BF=D0=B8=D1=81=D0=B5?= =?UTF-8?q?=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/App.tsx | 7 +- web/src/components/RecordEditor.test.tsx | 139 ++++++++++++++ web/src/components/RecordEditor.tsx | 111 +++++++++++ web/src/pages/TemplatesPage.test.tsx | 129 +++++++++++++ web/src/pages/TemplatesPage.tsx | 227 +++++++++++++++++++++++ 5 files changed, 608 insertions(+), 5 deletions(-) create mode 100644 web/src/components/RecordEditor.test.tsx create mode 100644 web/src/components/RecordEditor.tsx create mode 100644 web/src/pages/TemplatesPage.test.tsx create mode 100644 web/src/pages/TemplatesPage.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 0e281fd..78ee6a7 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -3,10 +3,7 @@ import { Layout } from "@/components/Layout" import { AccountsPage } from "@/pages/AccountsPage" import { DomainDiffPage } from "@/pages/DomainDiffPage" import { DomainsPage } from "@/pages/DomainsPage" - -function Placeholder({ name }: { name: string }) { - return
{name}
-} +import { TemplatesPage } from "@/pages/TemplatesPage" export function App() { return ( @@ -16,7 +13,7 @@ export function App() { } /> } /> } /> - } /> + } /> ) diff --git a/web/src/components/RecordEditor.test.tsx b/web/src/components/RecordEditor.test.tsx new file mode 100644 index 0000000..6bbf1ee --- /dev/null +++ b/web/src/components/RecordEditor.test.tsx @@ -0,0 +1,139 @@ +import { render, screen, fireEvent, waitFor } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { useState } from "react" +import { test, expect, vi } from "vitest" +import { RecordEditor } from "./RecordEditor" +import type { RecordDTO } from "@/api/types" + +function Harness({ + initial, + onChange, +}: { + initial: RecordDTO[] + onChange: (records: RecordDTO[]) => void +}) { + const [value, setValue] = useState(initial) + return ( + { + setValue(next) + onChange(next) + }} + /> + ) +} + +test("добавление записи вызывает onChange с новой пустой записью типа A", async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render() + + await user.click(screen.getByRole("button", { name: /добавить запись/i })) + + expect(onChange).toHaveBeenCalledWith([{ type: "A", name: "", ttl: 3600, values: [""] }]) +}) + +test("изменение полей записи вызывает onChange с корректным массивом", async () => { + const onChange = vi.fn() + render( + , + ) + + fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } }) + fireEvent.change(screen.getByLabelText(/ttl записи 1/i), { target: { value: "300" } }) + fireEvent.change(screen.getByLabelText(/значения записи 1/i), { + target: { value: "192.0.2.1" }, + }) + + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith([ + { type: "A", name: "www", ttl: 300, values: ["192.0.2.1"] }, + ]), + ) +}) + +test("несколько значений в textarea дают несколько элементов values", async () => { + const onChange = vi.fn() + render( + , + ) + + fireEvent.change(screen.getByLabelText(/значения записи 1/i), { + target: { value: "ns1.example.com.\nns2.example.com." }, + }) + + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith([ + { + type: "NS", + name: "@", + ttl: 3600, + values: ["ns1.example.com.", "ns2.example.com."], + }, + ]), + ) +}) + +test("выбор типа SRV и составное значение prio weight port target", async () => { + const onChange = vi.fn() + const user = userEvent.setup() + render( + , + ) + + await user.click(screen.getByRole("combobox", { name: /тип записи 1/i })) + await user.click(await screen.findByRole("option", { name: /^srv$/i })) + + fireEvent.change(screen.getByLabelText(/значения записи 1/i), { + target: { value: "10 5 5060 sipserver.example.com." }, + }) + + await waitFor(() => + expect(onChange).toHaveBeenLastCalledWith([ + { + type: "SRV", + name: "_sip._tcp", + ttl: 3600, + values: ["10 5 5060 sipserver.example.com."], + }, + ]), + ) +}) + +test("удаление записи вызывает onChange без удалённой записи", async () => { + const onChange = vi.fn() + const user = userEvent.setup() + const initial: RecordDTO[] = [ + { type: "A", name: "a", ttl: 3600, values: ["1.1.1.1"] }, + { type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] }, + ] + render() + + await user.click(screen.getByRole("button", { name: /удалить запись 1/i })) + + expect(onChange).toHaveBeenLastCalledWith([ + { type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] }, + ]) +}) + +test("mono-шрифт применён к полям name и values", () => { + render( + , + ) + + expect(screen.getByLabelText(/имя записи 1/i)).toHaveClass("font-dns") + expect(screen.getByLabelText(/значения записи 1/i)).toHaveClass("font-dns") +}) diff --git a/web/src/components/RecordEditor.tsx b/web/src/components/RecordEditor.tsx new file mode 100644 index 0000000..ce16c47 --- /dev/null +++ b/web/src/components/RecordEditor.tsx @@ -0,0 +1,111 @@ +import { Plus, Trash2 } from "lucide-react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import type { RecordDTO } from "@/api/types" + +const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "SOA"] as const + +const typeItems = RECORD_TYPES.map((t) => ({ value: t, label: t })) + +const DEFAULT_TTL = 3600 + +function emptyRecord(): RecordDTO { + return { type: "A", name: "", ttl: DEFAULT_TTL, values: [""] } +} + +export function RecordEditor({ + value, + onChange, +}: { + value: RecordDTO[] + onChange: (records: RecordDTO[]) => void +}) { + function updateRecord(index: number, patch: Partial) { + onChange(value.map((r, i) => (i === index ? { ...r, ...patch } : r))) + } + + function addRecord() { + onChange([...value, emptyRecord()]) + } + + function removeRecord(index: number) { + onChange(value.filter((_, i) => i !== index)) + } + + return ( +
+ {value.map((record, index) => ( +
+ + + updateRecord(index, { name: e.target.value })} + /> + + updateRecord(index, { ttl: Number(e.target.value) })} + /> + +