Merge feat/clone-template: клонирование существующих шаблонов

This commit is contained in:
2026-08-19 17:17:39 +07:00
2 changed files with 85 additions and 1 deletions
+54
View File
@@ -174,3 +174,57 @@ test("пустое состояние при отсутствии шаблоно
expect(await screen.findByText(/шаблонов пока нет/i)).toBeInTheDocument() 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()
})
+31 -1
View File
@@ -2,7 +2,7 @@ import { useId, useState } from "react"
import { Controller, useForm } from "react-hook-form" import { Controller, useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod" import { zodResolver } from "@hookform/resolvers/zod"
import { z } from "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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { import {
@@ -43,6 +43,18 @@ type TemplateForm = z.infer<typeof templateFormSchema>
const EMPTY_FORM: TemplateForm = { name: "", records: [] } 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"]) { function sanitizeRecords(records: TemplateForm["records"]) {
return records return records
.map((record) => ({ .map((record) => ({
@@ -78,6 +90,16 @@ export function TemplatesPage() {
reset({ name: template.name, records: template.records }) 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() { function onCancelEdit() {
setEditingId(null) setEditingId(null)
reset(EMPTY_FORM) reset(EMPTY_FORM)
@@ -225,6 +247,14 @@ export function TemplatesPage() {
> >
<Pencil className="size-3.5" strokeWidth={1.75} /> <Pencil className="size-3.5" strokeWidth={1.75} />
</Button> </Button>
<Button
variant="outline"
size="icon-sm"
aria-label={`Клонировать ${t.name}`}
onClick={() => onClone(t)}
>
<Copy className="size-3.5" strokeWidth={1.75} />
</Button>
<Button <Button
variant="destructive" variant="destructive"
size="icon-sm" size="icon-sm"