Compare commits

...
2 Commits
Author SHA1 Message Date
vasyansk ba58921dc7 Merge feat/clone-template: клонирование существующих шаблонов 2026-08-19 17:17:39 +07:00
vasyanskandClaude Opus 5 dcaf3e65f5 feat(web): clone existing templates
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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Yr8frsaxBgab1Aa7yfPuU
2026-08-19 17:17:34 +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"