Merge feat/clone-template: клонирование существующих шаблонов
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