Merge feat/clone-template: клонирование существующих шаблонов
This commit is contained in:
@@ -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()
|
||||||
|
})
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user