feat(web): TemplatesPage + RecordEditor — CRUD шаблонов с редактором записей
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user