feat(web): TemplatesPage + RecordEditor — CRUD шаблонов с редактором записей

This commit is contained in:
2026-07-03 18:02:08 +07:00
parent 4e91211a89
commit 99e09d35fb
5 changed files with 608 additions and 5 deletions
+129
View File
@@ -0,0 +1,129 @@
import { render, screen, waitFor, within, fireEvent } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
import { TemplatesPage } from "./TemplatesPage"
import { api } from "@/api/client"
import { vi, beforeEach, test, expect } from "vitest"
import type { Template } from "@/api/types"
const templates: Template[] = [
{
id: "t1",
name: "Standard",
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
version: 1,
},
{ id: "t2", name: "Minimal", records: [], version: 1 },
]
function renderPage() {
const qc = new QueryClient()
return render(
<QueryClientProvider client={qc}>
<MemoryRouter initialEntries={["/templates"]}>
<TemplatesPage />
</MemoryRouter>
</QueryClientProvider>,
)
}
beforeEach(() => {
vi.spyOn(api, "listTemplates").mockResolvedValue(templates)
})
test("отрисовывает список шаблонов с числом записей", async () => {
renderPage()
await screen.findByText("Standard")
const standardRow = screen.getByRole("row", { name: /standard/i })
expect(within(standardRow).getByText("1")).toBeInTheDocument()
const minimalRow = screen.getByRole("row", { name: /minimal/i })
expect(within(minimalRow).getByText("0")).toBeInTheDocument()
})
test("создание шаблона с записью вызывает api.createTemplate с {name, records}", async () => {
const createSpy = vi.spyOn(api, "createTemplate").mockResolvedValue({
id: "t3",
name: "New",
records: [],
version: 1,
})
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.type(screen.getByLabelText(/имя шаблона/i), "New")
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
fireEvent.change(screen.getByLabelText(/значения записи 1/i), { target: { value: "1.1.1.1" } })
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() =>
expect(createSpy).toHaveBeenCalledWith({
name: "New",
records: [{ type: "A", name: "www", ttl: 3600, values: ["1.1.1.1"] }],
}),
)
})
test("редактирование шаблона вызывает api.updateTemplate с обновлённым именем", async () => {
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 }))
const nameInput = screen.getByLabelText(/имя шаблона/i)
await user.clear(nameInput)
await user.type(nameInput, "Standard v2")
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
await waitFor(() =>
expect(updateSpy).toHaveBeenCalledWith("t1", {
name: "Standard v2",
records: [{ type: "A", name: "@", ttl: 3600, values: ["1.2.3.4"] }],
}),
)
})
test("удаление шаблона вызывает api.deleteTemplate", async () => {
const deleteSpy = vi.spyOn(api, "deleteTemplate").mockResolvedValue(undefined)
vi.spyOn(window, "confirm").mockReturnValue(true)
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.click(screen.getByRole("button", { name: /удалить шаблон standard/i }))
await waitFor(() => expect(deleteSpy).toHaveBeenCalledWith("t1"))
})
test("ошибка создания шаблона отображается пользователю", async () => {
vi.spyOn(api, "createTemplate").mockRejectedValue(new Error("Не удалось создать шаблон"))
const user = userEvent.setup()
renderPage()
await screen.findByText("Standard")
await user.type(screen.getByLabelText(/имя шаблона/i), "Broken")
await user.click(screen.getByRole("button", { name: /сохранить шаблон/i }))
expect(await screen.findByRole("alert")).toHaveTextContent("Не удалось создать шаблон")
})
test("пустое состояние при отсутствии шаблонов", async () => {
vi.spyOn(api, "listTemplates").mockResolvedValue([])
renderPage()
expect(await screen.findByText(/шаблонов пока нет/i)).toBeInTheDocument()
})
+227
View File
@@ -0,0 +1,227 @@
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 { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import {
Field,
FieldContent,
FieldError,
FieldGroup,
FieldLabel,
FieldSet,
} from "@/components/ui/field"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { RecordEditor } from "@/components/RecordEditor"
import { useCreateTemplate, useDeleteTemplate, useTemplates, useUpdateTemplate } from "@/hooks/useApi"
import type { Template } from "@/api/types"
const recordSchema = z.object({
type: z.string().min(1),
name: z.string().min(1, "Укажите имя записи"),
ttl: z.number().int("TTL — целое число").nonnegative("TTL не может быть отрицательным"),
values: z.array(z.string()),
})
const templateFormSchema = z.object({
name: z.string().min(1, "Укажите имя шаблона"),
records: z.array(recordSchema),
})
type TemplateForm = z.infer<typeof templateFormSchema>
const EMPTY_FORM: TemplateForm = { name: "", records: [] }
export function TemplatesPage() {
const templates = useTemplates()
const createTemplate = useCreateTemplate()
const updateTemplate = useUpdateTemplate()
const deleteTemplate = useDeleteTemplate()
const nameFieldId = useId()
const [editingId, setEditingId] = useState<string | null>(null)
const templateList = templates.data ?? []
const {
control,
handleSubmit,
reset,
formState: { errors },
} = useForm<TemplateForm>({
resolver: zodResolver(templateFormSchema),
defaultValues: EMPTY_FORM,
})
function onEdit(template: Template) {
setEditingId(template.id)
reset({ name: template.name, records: template.records })
}
function onCancelEdit() {
setEditingId(null)
reset(EMPTY_FORM)
}
function onSubmit(values: TemplateForm) {
if (editingId) {
updateTemplate.mutate(
{ id: editingId, input: values },
{
onSuccess: () => {
setEditingId(null)
reset(EMPTY_FORM)
},
},
)
} else {
createTemplate.mutate(values, { onSuccess: () => reset(EMPTY_FORM) })
}
}
function onDelete(id: string, name: string) {
if (window.confirm(`Удалить шаблон «${name}»? Действие необратимо.`)) {
if (editingId === id) onCancelEdit()
deleteTemplate.mutate(id)
}
}
const saveMutation = editingId ? updateTemplate : createTemplate
return (
<div className="mx-auto flex max-w-4xl flex-col gap-6 px-6 py-8">
<header className="flex flex-col gap-1">
<span className="font-dns text-[11px] tracking-wider text-muted-foreground uppercase">
templates
</span>
<h1 className="text-xl font-semibold tracking-tight text-foreground">Шаблоны</h1>
</header>
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col gap-4 rounded-xl border border-border bg-card/60 p-4"
>
<FieldSet className="gap-3">
<FieldGroup className="gap-3">
<Field className="sm:max-w-72">
<FieldLabel htmlFor={nameFieldId}>Имя шаблона</FieldLabel>
<FieldContent>
<Controller
control={control}
name="name"
render={({ field }) => (
<Input
{...field}
id={nameFieldId}
placeholder="Например, standard"
aria-invalid={!!errors.name}
/>
)}
/>
<FieldError errors={[errors.name]} />
</FieldContent>
</Field>
<Field>
<FieldLabel>Записи</FieldLabel>
<FieldContent>
<Controller
control={control}
name="records"
render={({ field }) => (
<RecordEditor value={field.value} onChange={field.onChange} />
)}
/>
</FieldContent>
</Field>
</FieldGroup>
</FieldSet>
<div className="flex items-center justify-between gap-3 border-t border-border pt-3">
{saveMutation.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{saveMutation.error.message}
</span>
)}
<div className="ml-auto flex items-center gap-2">
{editingId && (
<Button type="button" variant="ghost" onClick={onCancelEdit}>
<X className="size-4" strokeWidth={1.75} />
Отменить
</Button>
)}
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
) : (
<Save className="size-4" strokeWidth={1.75} />
)}
Сохранить шаблон
</Button>
</div>
</div>
</form>
{deleteTemplate.isError && (
<span role="alert" className="font-dns text-xs text-destructive">
{deleteTemplate.error.message}
</span>
)}
{templateList.length === 0 ? (
<div className="flex flex-col items-center gap-2 rounded-xl border border-dashed border-border px-4 py-12 text-center text-sm text-muted-foreground">
<Inbox className="size-6" strokeWidth={1.5} />
Шаблонов пока нет создайте первый выше.
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Записей</TableHead>
<TableHead className="text-right">Действия</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{templateList.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-dns">{t.name}</TableCell>
<TableCell>{t.records.length}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1.5">
<Button
variant="outline"
size="icon-sm"
aria-label={`Редактировать ${t.name}`}
onClick={() => onEdit(t)}
>
<Pencil className="size-3.5" strokeWidth={1.75} />
</Button>
<Button
variant="destructive"
size="icon-sm"
aria-label={`Удалить шаблон ${t.name}`}
onClick={() => onDelete(t.id, t.name)}
disabled={deleteTemplate.isPending}
>
<Trash2 className="size-3.5" strokeWidth={1.75} />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
)
}