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
This commit is contained in:
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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<typeof templateFormSchema>
|
||||
|
||||
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() {
|
||||
>
|
||||
<Pencil className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
aria-label={`Клонировать ${t.name}`}
|
||||
onClick={() => onClone(t)}
|
||||
>
|
||||
<Copy className="size-3.5" strokeWidth={1.75} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="icon-sm"
|
||||
|
||||
Reference in New Issue
Block a user