feat(web): per-record apply checkboxes with select-all; prune opt-in
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -99,12 +99,13 @@ describe("api client", () => {
|
|||||||
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError)
|
await expect(api.listDomains(PROJECT_ID)).rejects.toThrow(UnauthorizedError)
|
||||||
})
|
})
|
||||||
|
|
||||||
it("applies with prune flag using projectId, id, body order", async () => {
|
it("applies with selected record keys using projectId, id, body order", async () => {
|
||||||
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
const spy = mockFetch({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
await api.applyDomain(PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true })
|
await api.applyDomain(PROJECT_ID, "d1", { updates: ["A a."], prunes: ["A b."] })
|
||||||
const [url, opts] = spy.mock.calls[0]
|
const [url, opts] = spy.mock.calls[0]
|
||||||
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`)
|
expect(url).toBe(`/api/v1/projects/${PROJECT_ID}/domains/d1/apply`)
|
||||||
expect(String((opts as RequestInit).body)).toContain("applyPrunes")
|
expect(String((opts as RequestInit).body)).toContain("prunes")
|
||||||
|
expect(JSON.parse(String((opts as RequestInit).body))).toEqual({ updates: ["A a."], prunes: ["A b."] })
|
||||||
})
|
})
|
||||||
|
|
||||||
it("checkDomain(projectId, id) hits project-scoped check path", async () => {
|
it("checkDomain(projectId, id) hits project-scoped check path", async () => {
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ export interface CreateChannelInput { type: string; config: object; secret: stri
|
|||||||
export interface CheckRun { id?: string; createdAt: string; result: object }
|
export interface CheckRun { id?: string; createdAt: string; result: object }
|
||||||
|
|
||||||
export interface RecordView {
|
export interface RecordView {
|
||||||
|
key: string // stable "TYPE name." identifier — used to select this record for Apply
|
||||||
kind: string // add | update | delete | in_sync
|
kind: string // add | update | delete | in_sync
|
||||||
type: string
|
type: string
|
||||||
name: string
|
name: string
|
||||||
@@ -51,4 +52,4 @@ export interface ChangesetResponse {
|
|||||||
readOnly: RecordView[]
|
readOnly: RecordView[]
|
||||||
inSyncCount: number
|
inSyncCount: number
|
||||||
}
|
}
|
||||||
export interface ApplyRequest { applyUpdates: boolean; applyPrunes: boolean }
|
export interface ApplyRequest { updates: string[]; prunes: string[] }
|
||||||
|
|||||||
@@ -1,16 +1,34 @@
|
|||||||
import { render, screen } from "@testing-library/react"
|
import { render, screen } from "@testing-library/react"
|
||||||
|
import userEvent from "@testing-library/user-event"
|
||||||
import { DiffView } from "./DiffView"
|
import { DiffView } from "./DiffView"
|
||||||
import type { ChangesetResponse } from "@/api/types"
|
import type { ChangesetResponse } from "@/api/types"
|
||||||
|
|
||||||
const cs: ChangesetResponse = {
|
const cs: ChangesetResponse = {
|
||||||
updates: [{ kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
|
updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }],
|
||||||
prunes: [{ kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
|
prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }],
|
||||||
readOnly: [{ kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
|
readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }],
|
||||||
inSyncCount: 3,
|
inSyncCount: 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function noop() { /* unused in most tests */ }
|
||||||
|
|
||||||
|
function renderDiff(overrides: Partial<Parameters<typeof DiffView>[0]> = {}) {
|
||||||
|
return render(
|
||||||
|
<DiffView
|
||||||
|
changeset={cs}
|
||||||
|
selectedUpdates={new Set(["A www.example.com."])}
|
||||||
|
selectedPrunes={new Set()}
|
||||||
|
onToggleUpdate={noop}
|
||||||
|
onTogglePrune={noop}
|
||||||
|
onToggleAllUpdates={noop}
|
||||||
|
onToggleAllPrunes={noop}
|
||||||
|
{...overrides}
|
||||||
|
/>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
test("renders all sections with counts", () => {
|
test("renders all sections with counts", () => {
|
||||||
render(<DiffView changeset={cs} />)
|
renderDiff()
|
||||||
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
|
expect(screen.getByText(/www\.example\.com\./)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
|
expect(screen.getByText(/old\.example\.com\./)).toBeInTheDocument()
|
||||||
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
|
// Anchored (vs. the brief's bare /example\.com\./) — "www.example.com." and
|
||||||
@@ -23,7 +41,7 @@ test("renders all sections with counts", () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test("marks read-only records", () => {
|
test("marks read-only records", () => {
|
||||||
render(<DiffView changeset={cs} />)
|
renderDiff()
|
||||||
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
expect(screen.getByText(/NS/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -35,6 +53,7 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
|
|||||||
const csWithDkim: ChangesetResponse = {
|
const csWithDkim: ChangesetResponse = {
|
||||||
updates: [
|
updates: [
|
||||||
{
|
{
|
||||||
|
key: "TXT default._domainkey.example.com.",
|
||||||
kind: "update",
|
kind: "update",
|
||||||
type: "TXT",
|
type: "TXT",
|
||||||
name: "default._domainkey.example.com.",
|
name: "default._domainkey.example.com.",
|
||||||
@@ -47,7 +66,7 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () =>
|
|||||||
readOnly: [],
|
readOnly: [],
|
||||||
inSyncCount: 0,
|
inSyncCount: 0,
|
||||||
}
|
}
|
||||||
render(<DiffView changeset={csWithDkim} />)
|
renderDiff({ changeset: csWithDkim, selectedUpdates: new Set() })
|
||||||
expect(screen.getByText(new RegExp(longValue))).toBeInTheDocument()
|
expect(screen.getByText(new RegExp(longValue))).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -60,7 +79,55 @@ test("does not crash when changeset fields are null", () => {
|
|||||||
readOnly: null,
|
readOnly: null,
|
||||||
inSyncCount: 5,
|
inSyncCount: 5,
|
||||||
} as unknown as ChangesetResponse
|
} as unknown as ChangesetResponse
|
||||||
render(<DiffView changeset={nullish} />)
|
renderDiff({ changeset: nullish, selectedUpdates: new Set() })
|
||||||
expect(screen.getByText(/5/)).toBeInTheDocument()
|
expect(screen.getByText(/5/)).toBeInTheDocument()
|
||||||
expect(screen.getByText(/in sync/)).toBeInTheDocument()
|
expect(screen.getByText(/in sync/)).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("renders a checkbox for update and prune rows but not for read-only rows", () => {
|
||||||
|
renderDiff()
|
||||||
|
// 2 select-all (update + prune headers) + 2 row checkboxes (one update, one prune).
|
||||||
|
// Read-only section contributes none: no select-all, no row checkbox.
|
||||||
|
const checkboxes = screen.getAllByRole("checkbox")
|
||||||
|
expect(checkboxes).toHaveLength(4)
|
||||||
|
|
||||||
|
const updateRowCheckbox = screen.getByRole("checkbox", { name: /www\.example\.com\./ })
|
||||||
|
expect(updateRowCheckbox).toBeInTheDocument()
|
||||||
|
const pruneRowCheckbox = screen.getByRole("checkbox", { name: /old\.example\.com\./ })
|
||||||
|
expect(pruneRowCheckbox).toBeInTheDocument()
|
||||||
|
|
||||||
|
expect(screen.queryByRole("checkbox", { name: /example\.com\..*NS|NS.*example\.com\./ })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking an update row checkbox calls onToggleUpdate with the record key", async () => {
|
||||||
|
const onToggleUpdate = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderDiff({ onToggleUpdate })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /www\.example\.com\./ }))
|
||||||
|
expect(onToggleUpdate).toHaveBeenCalledWith("A www.example.com.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("clicking a prune row checkbox calls onTogglePrune with the record key", async () => {
|
||||||
|
const onTogglePrune = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderDiff({ onTogglePrune })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /old\.example\.com\./ }))
|
||||||
|
expect(onTogglePrune).toHaveBeenCalledWith("A old.example.com.")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("select-all header checkbox is checked when all rows in the section are selected", () => {
|
||||||
|
renderDiff({ selectedUpdates: new Set(["A www.example.com."]) })
|
||||||
|
const selectAll = screen.getByRole("checkbox", { name: /выбрать все.*updates/i })
|
||||||
|
expect(selectAll).toHaveAttribute("aria-checked", "true")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("select-all header checkbox calls onToggleAllUpdates(true) when clicked while none selected", async () => {
|
||||||
|
const onToggleAllUpdates = vi.fn()
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderDiff({ selectedUpdates: new Set(), onToggleAllUpdates })
|
||||||
|
|
||||||
|
await user.click(screen.getByRole("checkbox", { name: /выбрать все.*updates/i }))
|
||||||
|
expect(onToggleAllUpdates).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react"
|
import type { ReactNode } from "react"
|
||||||
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChangesetResponse, RecordView } from "@/api/types"
|
import type { ChangesetResponse, RecordView } from "@/api/types"
|
||||||
|
|
||||||
@@ -40,9 +41,23 @@ function Values({ values }: { values?: string[] }) {
|
|||||||
return <>{values.join(", ")}</>
|
return <>{values.join(", ")}</>
|
||||||
}
|
}
|
||||||
|
|
||||||
function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
function RecordRow({
|
||||||
|
record,
|
||||||
|
tone,
|
||||||
|
checked,
|
||||||
|
onToggle,
|
||||||
|
}: {
|
||||||
|
record: RecordView
|
||||||
|
tone: Tone
|
||||||
|
checked?: boolean
|
||||||
|
onToggle?: (key: string) => void
|
||||||
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const showArrow = tone !== "delete"
|
const showArrow = tone !== "delete"
|
||||||
|
// Read-only records aren't selectable — onToggle is only passed for
|
||||||
|
// update/delete sections. Presence of onToggle is the selectability flag,
|
||||||
|
// not the tone, so this stays correct if a tone's selectability ever changes.
|
||||||
|
const selectable = onToggle !== undefined
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -52,9 +67,18 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
|||||||
)}
|
)}
|
||||||
style={{ borderLeftColor: meta.dot }}
|
style={{ borderLeftColor: meta.dot }}
|
||||||
>
|
>
|
||||||
{/* Top line: type badge, name, read-only flag — always single-line,
|
{/* Top line: (optional) checkbox, type badge, name, read-only flag —
|
||||||
never affected by how long the record values are. */}
|
always single-line, never affected by how long the record values are. */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{selectable && (
|
||||||
|
<Checkbox
|
||||||
|
checked={checked ?? false}
|
||||||
|
onCheckedChange={() => onToggle!(record.key)}
|
||||||
|
aria-label={`${tone === "delete" ? "Удалить" : "Применить"} ${record.type} ${record.name}`}
|
||||||
|
className="shrink-0"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
|
className="font-dns w-11 shrink-0 justify-center border-border text-[10px] tracking-wide text-muted-foreground"
|
||||||
@@ -81,8 +105,14 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
|||||||
unbreakable value like a DKIM key wraps within the row's own
|
unbreakable value like a DKIM key wraps within the row's own
|
||||||
width instead of stretching it — a flex item's content can
|
width instead of stretching it — a flex item's content can
|
||||||
otherwise refuse to shrink below its intrinsic width. Indented
|
otherwise refuse to shrink below its intrinsic width. Indented
|
||||||
to align under the name (badge width + gap). */}
|
to align under the name (badge width + gap, plus checkbox width +
|
||||||
<div className="font-dns hidden pl-14 text-xs leading-relaxed break-all text-muted-foreground sm:block">
|
gap when this row is selectable). */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"font-dns hidden text-xs leading-relaxed break-all text-muted-foreground sm:block",
|
||||||
|
selectable ? "pl-[5.25rem]" : "pl-14",
|
||||||
|
)}
|
||||||
|
>
|
||||||
<Values values={record.actual} />
|
<Values values={record.actual} />
|
||||||
{showArrow && (
|
{showArrow && (
|
||||||
<>
|
<>
|
||||||
@@ -104,16 +134,36 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
|||||||
function Section({
|
function Section({
|
||||||
tone,
|
tone,
|
||||||
records,
|
records,
|
||||||
|
selected,
|
||||||
|
onToggle,
|
||||||
|
onToggleAll,
|
||||||
}: {
|
}: {
|
||||||
tone: Tone
|
tone: Tone
|
||||||
records: RecordView[]
|
records: RecordView[]
|
||||||
|
selected?: Set<string>
|
||||||
|
onToggle?: (key: string) => void
|
||||||
|
onToggleAll?: (checked: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
|
// Read-only (NS/SOA) records are never selectable — only update/delete
|
||||||
|
// sections receive selection props from DiffView.
|
||||||
|
const selectable = tone !== "readonly" && !!selected && !!onToggle && !!onToggleAll
|
||||||
|
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
|
||||||
|
const someSelected = selectable && records.some((r) => selected!.has(r.key))
|
||||||
|
const indeterminate = someSelected && !allSelected
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label={meta.label} className="flex flex-col gap-2">
|
<section aria-label={meta.label} className="flex flex-col gap-2">
|
||||||
<header className="flex items-center gap-2 px-0.5">
|
<header className="flex items-center gap-2 px-0.5">
|
||||||
|
{selectable && records.length > 0 && (
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
indeterminate={indeterminate}
|
||||||
|
onCheckedChange={(v) => onToggleAll!(v === true)}
|
||||||
|
aria-label={`Выбрать все — ${meta.label}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
|
<Icon className="size-3.5" strokeWidth={1.75} style={{ color: meta.dot }} />
|
||||||
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
||||||
{meta.label}
|
{meta.label}
|
||||||
@@ -134,8 +184,14 @@ function Section({
|
|||||||
meta.ring,
|
meta.ring,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{records.map((record, i) => (
|
{records.map((record) => (
|
||||||
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
|
<RecordRow
|
||||||
|
key={record.key}
|
||||||
|
record={record}
|
||||||
|
tone={tone}
|
||||||
|
checked={selectable ? selected!.has(record.key) : undefined}
|
||||||
|
onToggle={selectable ? onToggle : undefined}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -145,17 +201,41 @@ function Section({
|
|||||||
|
|
||||||
export function DiffView({
|
export function DiffView({
|
||||||
changeset,
|
changeset,
|
||||||
|
selectedUpdates,
|
||||||
|
selectedPrunes,
|
||||||
|
onToggleUpdate,
|
||||||
|
onTogglePrune,
|
||||||
|
onToggleAllUpdates,
|
||||||
|
onToggleAllPrunes,
|
||||||
footerExtra,
|
footerExtra,
|
||||||
}: {
|
}: {
|
||||||
changeset: ChangesetResponse
|
changeset: ChangesetResponse
|
||||||
|
selectedUpdates: Set<string>
|
||||||
|
selectedPrunes: Set<string>
|
||||||
|
onToggleUpdate: (key: string) => void
|
||||||
|
onTogglePrune: (key: string) => void
|
||||||
|
onToggleAllUpdates: (checked: boolean) => void
|
||||||
|
onToggleAllPrunes: (checked: boolean) => void
|
||||||
footerExtra?: ReactNode
|
footerExtra?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
||||||
// backend) — normalise to [] so Section never calls .length/.map on null.
|
// backend) — normalise to [] so Section never calls .length/.map on null.
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-6">
|
||||||
<Section tone="update" records={changeset.updates ?? []} />
|
<Section
|
||||||
<Section tone="delete" records={changeset.prunes ?? []} />
|
tone="update"
|
||||||
|
records={changeset.updates ?? []}
|
||||||
|
selected={selectedUpdates}
|
||||||
|
onToggle={onToggleUpdate}
|
||||||
|
onToggleAll={onToggleAllUpdates}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
tone="delete"
|
||||||
|
records={changeset.prunes ?? []}
|
||||||
|
selected={selectedPrunes}
|
||||||
|
onToggle={onTogglePrune}
|
||||||
|
onToggleAll={onToggleAllPrunes}
|
||||||
|
/>
|
||||||
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
||||||
|
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ beforeEach(() => {
|
|||||||
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
|
vi.spyOn(api, "listDomains").mockResolvedValue([domainWithTemplate])
|
||||||
})
|
})
|
||||||
|
|
||||||
test("apply sends applyPrunes=false by default, true only after opting in", async () => {
|
test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => {
|
||||||
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
||||||
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
||||||
readOnly: [], inSyncCount: 0,
|
readOnly: [], inSyncCount: 0,
|
||||||
})
|
})
|
||||||
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
@@ -52,22 +52,51 @@ test("apply sends applyPrunes=false by default, true only after opting in", asyn
|
|||||||
const user = userEvent.setup()
|
const user = userEvent.setup()
|
||||||
renderPage()
|
renderPage()
|
||||||
|
|
||||||
const applyBtn = await screen.findByRole("button", { name: /apply/i })
|
const updateRowCheckbox = await screen.findByRole("checkbox", { name: /a\.$/ })
|
||||||
|
const pruneRowCheckbox = screen.getByRole("checkbox", { name: /b\.$/ })
|
||||||
|
expect(updateRowCheckbox).toHaveAttribute("aria-checked", "true")
|
||||||
|
expect(pruneRowCheckbox).toHaveAttribute("aria-checked", "false")
|
||||||
|
expect(screen.queryByText(/будет удалено записей/i)).not.toBeInTheDocument()
|
||||||
|
|
||||||
|
const applyBtn = screen.getByRole("button", { name: /apply/i })
|
||||||
await user.click(applyBtn)
|
await user.click(applyBtn)
|
||||||
await waitFor(() => expect(applySpy).toHaveBeenCalled())
|
await waitFor(() => expect(applySpy).toHaveBeenCalled())
|
||||||
expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: false }])
|
expect(applySpy.mock.calls[0]).toEqual([PROJECT_ID, "d1", { updates: ["A a."], prunes: [] }])
|
||||||
|
|
||||||
|
// отметить prune → появляется предупреждение с количеством, и Apply шлёт его ключ тоже
|
||||||
|
await user.click(pruneRowCheckbox)
|
||||||
|
const warning = screen.getByRole("alert")
|
||||||
|
expect(warning).toHaveTextContent(/будет удалено записей:\s*1/i)
|
||||||
|
|
||||||
// включить prune и применить снова
|
|
||||||
const pruneToggle = screen.getByRole("checkbox", { name: /prune|удал/i })
|
|
||||||
await user.click(pruneToggle)
|
|
||||||
await user.click(screen.getByRole("button", { name: /apply/i }))
|
await user.click(screen.getByRole("button", { name: /apply/i }))
|
||||||
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
await waitFor(() => expect(applySpy).toHaveBeenCalledTimes(2))
|
||||||
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { applyUpdates: true, applyPrunes: true }])
|
expect(applySpy.mock.calls[1]).toEqual([PROJECT_ID, "d1", { updates: ["A a."], prunes: ["A b."] }])
|
||||||
|
|
||||||
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
|
// домен с шаблоном: записи зоны не нужны для диффа — запрос не должен уходить к провайдеру
|
||||||
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
expect(zoneRecordsSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("deselecting all records disables Apply", async () => {
|
||||||
|
vi.spyOn(api, "checkDomain").mockResolvedValue({
|
||||||
|
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
||||||
|
prunes: [],
|
||||||
|
readOnly: [], inSyncCount: 0,
|
||||||
|
})
|
||||||
|
vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 })
|
||||||
|
const user = userEvent.setup()
|
||||||
|
renderPage()
|
||||||
|
|
||||||
|
const applyBtn = await screen.findByRole("button", { name: /apply/i })
|
||||||
|
expect(applyBtn).not.toBeDisabled()
|
||||||
|
expect(screen.getByText(/готово к применению/i)).toBeInTheDocument()
|
||||||
|
|
||||||
|
const updateRowCheckbox = screen.getByRole("checkbox", { name: /a\.$/ })
|
||||||
|
await user.click(updateRowCheckbox)
|
||||||
|
|
||||||
|
expect(applyBtn).toBeDisabled()
|
||||||
|
expect(screen.getByText(/изменений для применения нет/i)).toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
|
test("пока список доменов грузится — показан общий лоадер, а не баннер об отсутствии шаблона", async () => {
|
||||||
let resolveListDomains: (domains: Domain[]) => void
|
let resolveListDomains: (domains: Domain[]) => void
|
||||||
vi.spyOn(api, "listDomains").mockReturnValue(
|
vi.spyOn(api, "listDomains").mockReturnValue(
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useId, useState } from "react"
|
import { useEffect, useState } from "react"
|
||||||
import { useParams } from "react-router-dom"
|
import { useParams } from "react-router-dom"
|
||||||
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
import { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
||||||
import { DiffView } from "@/components/DiffView"
|
import { DiffView } from "@/components/DiffView"
|
||||||
import { DomainHistory } from "@/components/DomainHistory"
|
import { DomainHistory } from "@/components/DomainHistory"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -37,17 +35,48 @@ export function DomainDiffPage() {
|
|||||||
// (успешный ответ), что шаблона нет.
|
// (успешный ответ), что шаблона нет.
|
||||||
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
||||||
const createTemplateFromZone = useCreateTemplateFromZone()
|
const createTemplateFromZone = useCreateTemplateFromZone()
|
||||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
|
||||||
const pruneCheckboxId = useId()
|
const [selectedPrunes, setSelectedPrunes] = useState<Set<string>>(new Set())
|
||||||
|
|
||||||
const changeset = check.data
|
const changeset = check.data
|
||||||
const hasPrunes = (changeset?.prunes?.length ?? 0) > 0
|
|
||||||
const hasUpdates = (changeset?.updates?.length ?? 0) > 0
|
// Re-derive the selection whenever the changeset changes (initial load,
|
||||||
const pruneWarning = applyPrunes && hasPrunes
|
// recheck, or apply's own invalidation): updates default to fully selected
|
||||||
|
// (safe — they only bring the zone in line with the template), prunes
|
||||||
|
// default to empty (deletion is opt-in and irreversible).
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedUpdates(new Set((changeset?.updates ?? []).map((r) => r.key)))
|
||||||
|
setSelectedPrunes(new Set())
|
||||||
|
}, [changeset])
|
||||||
|
|
||||||
const recordList = zoneRecords.data ?? []
|
const recordList = zoneRecords.data ?? []
|
||||||
|
const hasSelection = selectedUpdates.size + selectedPrunes.size > 0
|
||||||
|
|
||||||
|
function toggleUpdate(key: string) {
|
||||||
|
setSelectedUpdates((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function togglePrune(key: string) {
|
||||||
|
setSelectedPrunes((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(key)) next.delete(key)
|
||||||
|
else next.add(key)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function toggleAllUpdates(checked: boolean) {
|
||||||
|
setSelectedUpdates(checked ? new Set((changeset?.updates ?? []).map((r) => r.key)) : new Set())
|
||||||
|
}
|
||||||
|
function toggleAllPrunes(checked: boolean) {
|
||||||
|
setSelectedPrunes(checked ? new Set((changeset?.prunes ?? []).map((r) => r.key)) : new Set())
|
||||||
|
}
|
||||||
|
|
||||||
function onApply() {
|
function onApply() {
|
||||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCreateTemplateFromZone() {
|
function onCreateTemplateFromZone() {
|
||||||
@@ -197,36 +226,18 @@ export function DomainDiffPage() {
|
|||||||
|
|
||||||
{hasTemplate && changeset && (
|
{hasTemplate && changeset && (
|
||||||
<>
|
<>
|
||||||
<DiffView changeset={changeset} />
|
<DiffView
|
||||||
|
changeset={changeset}
|
||||||
|
selectedUpdates={selectedUpdates}
|
||||||
|
selectedPrunes={selectedPrunes}
|
||||||
|
onToggleUpdate={toggleUpdate}
|
||||||
|
onTogglePrune={togglePrune}
|
||||||
|
onToggleAllUpdates={toggleAllUpdates}
|
||||||
|
onToggleAllPrunes={toggleAllPrunes}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
|
||||||
<Label
|
{selectedPrunes.size > 0 && (
|
||||||
htmlFor={pruneCheckboxId}
|
|
||||||
className="flex items-start gap-2.5 text-sm font-normal"
|
|
||||||
>
|
|
||||||
<Checkbox
|
|
||||||
id={pruneCheckboxId}
|
|
||||||
aria-label="prune — удалить лишние записи"
|
|
||||||
checked={applyPrunes}
|
|
||||||
onCheckedChange={(v) => setApplyPrunes(v === true)}
|
|
||||||
className="mt-0.5"
|
|
||||||
style={
|
|
||||||
applyPrunes
|
|
||||||
? ({ borderColor: "var(--diff-delete)", background: "var(--diff-delete)" } as React.CSSProperties)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<span className="flex flex-col gap-0.5">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
Prune — удалить записи, которых нет в шаблоне
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
По умолчанию выключено. Apply меняет только записи из шаблона.
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
|
|
||||||
{pruneWarning && (
|
|
||||||
<div
|
<div
|
||||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
||||||
style={{
|
style={{
|
||||||
@@ -237,8 +248,8 @@ export function DomainDiffPage() {
|
|||||||
>
|
>
|
||||||
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
||||||
<span>
|
<span>
|
||||||
Будет безвозвратно удалено записей:{" "}
|
Будет удалено записей:{" "}
|
||||||
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
|
<span className="font-dns font-semibold">{selectedPrunes.size}</span>. Действие необратимо.
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -252,12 +263,10 @@ export function DomainDiffPage() {
|
|||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-muted-foreground">
|
<span className="text-xs text-muted-foreground">
|
||||||
{hasUpdates || (applyPrunes && hasPrunes)
|
{hasSelection ? "Готово к применению" : "Изменений для применения нет"}
|
||||||
? "Готово к применению"
|
|
||||||
: "Изменений для применения нет"}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<Button onClick={onApply} disabled={apply.isPending}>
|
<Button onClick={onApply} disabled={apply.isPending || !hasSelection}>
|
||||||
{apply.isPending ? (
|
{apply.isPending ? (
|
||||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
Reference in New Issue
Block a user