From dcaf3e65f59167061bca7c58c2d23a5a193a93be Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 17:17:34 +0700 Subject: [PATCH] feat(web): clone existing templates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Clone" action to the templates table: it fills the create form with a copy of the selected template's name and records instead of creating the copy right away, so the operator can adjust the name or records before saving. No new API — the existing POST /templates is reused. Cloning resets editingId so the submit goes to createTemplate even when edit mode was active, and the copy name is picked to be free among the listed templates ("X (копия)", then "X (копия 2)"). Template names have no unique constraint in the DB, so this is a UX hint, not a guarantee. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU --- web/src/pages/TemplatesPage.test.tsx | 54 ++++++++++++++++++++++++++++ web/src/pages/TemplatesPage.tsx | 32 ++++++++++++++++- 2 files changed, 85 insertions(+), 1 deletion(-) 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() { > +