feat(web): TemplatesPage + RecordEditor — CRUD шаблонов с редактором записей
This commit is contained in:
+2
-5
@@ -3,10 +3,7 @@ import { Layout } from "@/components/Layout"
|
|||||||
import { AccountsPage } from "@/pages/AccountsPage"
|
import { AccountsPage } from "@/pages/AccountsPage"
|
||||||
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
import { DomainDiffPage } from "@/pages/DomainDiffPage"
|
||||||
import { DomainsPage } from "@/pages/DomainsPage"
|
import { DomainsPage } from "@/pages/DomainsPage"
|
||||||
|
import { TemplatesPage } from "@/pages/TemplatesPage"
|
||||||
function Placeholder({ name }: { name: string }) {
|
|
||||||
return <div className="p-8 text-2xl">{name}</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -16,7 +13,7 @@ export function App() {
|
|||||||
<Route path="/domains" element={<DomainsPage />} />
|
<Route path="/domains" element={<DomainsPage />} />
|
||||||
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
<Route path="/domains/:id" element={<DomainDiffPage />} />
|
||||||
<Route path="/accounts" element={<AccountsPage />} />
|
<Route path="/accounts" element={<AccountsPage />} />
|
||||||
<Route path="/templates" element={<Placeholder name="Templates" />} />
|
<Route path="/templates" element={<TemplatesPage />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import { render, screen, fireEvent, waitFor } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
|
import { useState } from "react"
|
||||||
|
import { test, expect, vi } from "vitest"
|
||||||
|
import { RecordEditor } from "./RecordEditor"
|
||||||
|
import type { RecordDTO } from "@/api/types"
|
||||||
|
|
||||||
|
function Harness({
|
||||||
|
initial,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
initial: RecordDTO[]
|
||||||
|
onChange: (records: RecordDTO[]) => void
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState<RecordDTO[]>(initial)
|
||||||
|
return (
|
||||||
|
<RecordEditor
|
||||||
|
value={value}
|
||||||
|
onChange={(next) => {
|
||||||
|
setValue(next)
|
||||||
|
onChange(next)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
test("добавление записи вызывает onChange с новой пустой записью типа A", async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(<Harness initial={[]} onChange={onChange} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /добавить запись/i }))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenCalledWith([{ type: "A", name: "", ttl: 3600, values: [""] }])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("изменение полей записи вызывает onChange с корректным массивом", async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
initial={[{ type: "A", name: "", ttl: 3600, values: [""] }]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/имя записи 1/i), { target: { value: "www" } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/ttl записи 1/i), { target: { value: "300" } })
|
||||||
|
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
|
||||||
|
target: { value: "192.0.2.1" },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith([
|
||||||
|
{ type: "A", name: "www", ttl: 300, values: ["192.0.2.1"] },
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("несколько значений в textarea дают несколько элементов values", async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
initial={[{ type: "NS", name: "@", ttl: 3600, values: [""] }]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
|
||||||
|
target: { value: "ns1.example.com.\nns2.example.com." },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith([
|
||||||
|
{
|
||||||
|
type: "NS",
|
||||||
|
name: "@",
|
||||||
|
ttl: 3600,
|
||||||
|
values: ["ns1.example.com.", "ns2.example.com."],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("выбор типа SRV и составное значение prio weight port target", async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
initial={[{ type: "A", name: "_sip._tcp", ttl: 3600, values: [""] }]}
|
||||||
|
onChange={onChange}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("combobox", { name: /тип записи 1/i }))
|
||||||
|
await user.click(await screen.findByRole("option", { name: /^srv$/i }))
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/значения записи 1/i), {
|
||||||
|
target: { value: "10 5 5060 sipserver.example.com." },
|
||||||
|
})
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith([
|
||||||
|
{
|
||||||
|
type: "SRV",
|
||||||
|
name: "_sip._tcp",
|
||||||
|
ttl: 3600,
|
||||||
|
values: ["10 5 5060 sipserver.example.com."],
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("удаление записи вызывает onChange без удалённой записи", async () => {
|
||||||
|
const onChange = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
const initial: RecordDTO[] = [
|
||||||
|
{ type: "A", name: "a", ttl: 3600, values: ["1.1.1.1"] },
|
||||||
|
{ type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] },
|
||||||
|
]
|
||||||
|
render(<Harness initial={initial} onChange={onChange} />)
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("button", { name: /удалить запись 1/i }))
|
||||||
|
|
||||||
|
expect(onChange).toHaveBeenLastCalledWith([
|
||||||
|
{ type: "A", name: "b", ttl: 3600, values: ["2.2.2.2"] },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
test("mono-шрифт применён к полям name и values", () => {
|
||||||
|
render(
|
||||||
|
<Harness
|
||||||
|
initial={[{ type: "A", name: "www", ttl: 3600, values: ["1.2.3.4"] }]}
|
||||||
|
onChange={vi.fn()}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(screen.getByLabelText(/имя записи 1/i)).toHaveClass("font-dns")
|
||||||
|
expect(screen.getByLabelText(/значения записи 1/i)).toHaveClass("font-dns")
|
||||||
|
})
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { Plus, Trash2 } from "lucide-react"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Textarea } from "@/components/ui/textarea"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import type { RecordDTO } from "@/api/types"
|
||||||
|
|
||||||
|
const RECORD_TYPES = ["A", "AAAA", "CNAME", "MX", "TXT", "SRV", "NS", "SOA"] as const
|
||||||
|
|
||||||
|
const typeItems = RECORD_TYPES.map((t) => ({ value: t, label: t }))
|
||||||
|
|
||||||
|
const DEFAULT_TTL = 3600
|
||||||
|
|
||||||
|
function emptyRecord(): RecordDTO {
|
||||||
|
return { type: "A", name: "", ttl: DEFAULT_TTL, values: [""] }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RecordEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: RecordDTO[]
|
||||||
|
onChange: (records: RecordDTO[]) => void
|
||||||
|
}) {
|
||||||
|
function updateRecord(index: number, patch: Partial<RecordDTO>) {
|
||||||
|
onChange(value.map((r, i) => (i === index ? { ...r, ...patch } : r)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function addRecord() {
|
||||||
|
onChange([...value, emptyRecord()])
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRecord(index: number) {
|
||||||
|
onChange(value.filter((_, i) => i !== index))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{value.map((record, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-border bg-background/40 p-2.5 sm:flex-row sm:items-start"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
items={typeItems}
|
||||||
|
value={record.type}
|
||||||
|
onValueChange={(v) => updateRecord(index, { type: v as string })}
|
||||||
|
>
|
||||||
|
<SelectTrigger aria-label={`Тип записи ${index + 1}`} size="sm" className="font-dns sm:w-24">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{typeItems.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value} className="font-dns">
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
aria-label={`Имя записи ${index + 1}`}
|
||||||
|
className="font-dns sm:flex-1"
|
||||||
|
placeholder="www"
|
||||||
|
value={record.name}
|
||||||
|
onChange={(e) => updateRecord(index, { name: e.target.value })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
aria-label={`TTL записи ${index + 1}`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
className="font-dns sm:w-24"
|
||||||
|
value={record.ttl}
|
||||||
|
onChange={(e) => updateRecord(index, { ttl: Number(e.target.value) })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
aria-label={`Значения записи ${index + 1}`}
|
||||||
|
className="font-dns sm:flex-1"
|
||||||
|
placeholder="192.0.2.1"
|
||||||
|
rows={1}
|
||||||
|
value={record.values.join("\n")}
|
||||||
|
onChange={(e) => updateRecord(index, { values: e.target.value.split("\n") })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="destructive"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label={`Удалить запись ${index + 1}`}
|
||||||
|
onClick={() => removeRecord(index)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5" strokeWidth={1.75} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={addRecord} className="self-start">
|
||||||
|
<Plus className="size-3.5" strokeWidth={1.75} />
|
||||||
|
Добавить запись
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
})
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user