diff --git a/web/src/pages/TemplatesPage.test.tsx b/web/src/pages/TemplatesPage.test.tsx index cc2cdab..9e4eda9 100644 --- a/web/src/pages/TemplatesPage.test.tsx +++ b/web/src/pages/TemplatesPage.test.tsx @@ -174,3 +174,57 @@ test("пустое состояние при отсутствии шаблоно expect(await screen.findByText(/шаблонов пока нет/i)).toBeInTheDocument() }) + +test("клонирование заполняет форму копией записей и именем «(копия)»", async () => { + const user = userEvent.setup() + renderPage() + + await screen.findByText("Standard") + + await user.click(screen.getByRole("button", { name: /клонировать standard/i })) + + expect(screen.getByLabelText(/имя шаблона/i)).toHaveValue("Standard (копия)") + expect(screen.getByLabelText(/имя записи 1/i)).toHaveValue("@") + expect(screen.getByLabelText(/значения записи 1/i)).toHaveValue("1.2.3.4") +}) + +test("клон существующей копии получает суффикс «(копия 2)»", async () => { + vi.spyOn(api, "listTemplates").mockResolvedValue([ + ...templates, + { id: "t3", name: "Standard (копия)", records: [], version: 1 }, + ]) + const user = userEvent.setup() + renderPage() + + await screen.findByText("Standard (копия)") + + await user.click(screen.getByRole("button", { name: /клонировать standard$/i })) + + expect(screen.getByLabelText(/имя шаблона/i)).toHaveValue("Standard (копия 2)") +}) + +test("клонирование во время редактирования уходит в createTemplate, а не в updateTemplate", async () => { + const createSpy = vi.spyOn(api, "createTemplate").mockResolvedValue({ + id: "t3", + name: "Standard (копия)", + records: templates[0].records, + version: 1, + }) + const updateSpy = vi.spyOn(api, "updateTemplate").mockResolvedValue(templates[0]) + const user = userEvent.setup() + renderPage() + + await screen.findByText("Standard") + + await user.click(screen.getByRole("button", { name: /редактировать standard/i })) + await user.click(screen.getByRole("button", { name: /клонировать standard$/i })) + await user.click(screen.getByRole("button", { name: /сохранить шаблон/i })) + + await waitFor(() => + expect(createSpy).toHaveBeenCalledWith(PROJECT_ID, { + name: "Standard (копия)", + records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }], + }), + ) + expect(updateSpy).not.toHaveBeenCalled() +}) diff --git a/web/src/pages/TemplatesPage.tsx b/web/src/pages/TemplatesPage.tsx index 6c99aad..3effdb6 100644 --- a/web/src/pages/TemplatesPage.tsx +++ b/web/src/pages/TemplatesPage.tsx @@ -2,7 +2,7 @@ import { useId, useState } from "react" import { Controller, useForm } from "react-hook-form" import { zodResolver } from "@hookform/resolvers/zod" import { z } from "zod" -import { Inbox, Loader2, Pencil, Save, Trash2, X } from "lucide-react" +import { Copy, Inbox, Loader2, Pencil, Save, Trash2, X } from "lucide-react" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { @@ -43,6 +43,18 @@ type TemplateForm = z.infer const EMPTY_FORM: TemplateForm = { name: "", records: [] } +// uniqueCopyName подбирает имя клона, не занятое среди существующих шаблонов: +// «Standard (копия)», затем «Standard (копия 2)» и далее. Уникальность имени в БД +// не проверяется (нет constraint) — это UX-подсказка, а не гарантия. +function uniqueCopyName(base: string, templates: Template[]) { + const taken = new Set(templates.map((t) => t.name)) + let candidate = `${base} (копия)` + for (let n = 2; taken.has(candidate); n++) { + candidate = `${base} (копия ${n})` + } + return candidate +} + function sanitizeRecords(records: TemplateForm["records"]) { return records .map((record) => ({ @@ -78,6 +90,16 @@ export function TemplatesPage() { reset({ name: template.name, records: template.records }) } + // onClone кладёт копию в форму создания: editingId сбрасывается, поэтому + // сабмит уходит в createTemplate даже если до этого шло редактирование. + function onClone(template: Template) { + setEditingId(null) + reset({ + name: uniqueCopyName(template.name, templateList), + records: template.records.map((r) => ({ ...r, values: [...r.values] })), + }) + } + function onCancelEdit() { setEditingId(null) reset(EMPTY_FORM) @@ -225,6 +247,14 @@ export function TemplatesPage() { > +