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:
@@ -41,10 +41,10 @@ beforeEach(() => {
|
||||
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({
|
||||
updates: [{ kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
||||
prunes: [{ kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
||||
updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }],
|
||||
prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }],
|
||||
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()
|
||||
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 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 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()
|
||||
})
|
||||
|
||||
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 () => {
|
||||
let resolveListDomains: (domains: Domain[]) => void
|
||||
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 { AlertTriangle, Loader2, Play, RefreshCw, TriangleAlert } from "lucide-react"
|
||||
import { DiffView } from "@/components/DiffView"
|
||||
import { DomainHistory } from "@/components/DomainHistory"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -37,17 +35,48 @@ export function DomainDiffPage() {
|
||||
// (успешный ответ), что шаблона нет.
|
||||
const zoneRecords = useZoneRecords(id, !domains.isPending && !domains.isError && !hasTemplate)
|
||||
const createTemplateFromZone = useCreateTemplateFromZone()
|
||||
const [applyPrunes, setApplyPrunes] = useState(false)
|
||||
const pruneCheckboxId = useId()
|
||||
const [selectedUpdates, setSelectedUpdates] = useState<Set<string>>(new Set())
|
||||
const [selectedPrunes, setSelectedPrunes] = useState<Set<string>>(new Set())
|
||||
|
||||
const changeset = check.data
|
||||
const hasPrunes = (changeset?.prunes?.length ?? 0) > 0
|
||||
const hasUpdates = (changeset?.updates?.length ?? 0) > 0
|
||||
const pruneWarning = applyPrunes && hasPrunes
|
||||
|
||||
// Re-derive the selection whenever the changeset changes (initial load,
|
||||
// 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 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() {
|
||||
apply.mutate({ applyUpdates: true, applyPrunes })
|
||||
apply.mutate({ updates: [...selectedUpdates], prunes: [...selectedPrunes] })
|
||||
}
|
||||
|
||||
function onCreateTemplateFromZone() {
|
||||
@@ -197,36 +226,18 @@ export function DomainDiffPage() {
|
||||
|
||||
{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">
|
||||
<Label
|
||||
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 && (
|
||||
{selectedPrunes.size > 0 && (
|
||||
<div
|
||||
className="flex items-start gap-2 rounded-lg px-3 py-2 text-xs"
|
||||
style={{
|
||||
@@ -237,8 +248,8 @@ export function DomainDiffPage() {
|
||||
>
|
||||
<TriangleAlert className="mt-px size-3.5 shrink-0" strokeWidth={2} />
|
||||
<span>
|
||||
Будет безвозвратно удалено записей:{" "}
|
||||
<span className="font-dns font-semibold">{changeset.prunes.length}</span>. Действие необратимо.
|
||||
Будет удалено записей:{" "}
|
||||
<span className="font-dns font-semibold">{selectedPrunes.size}</span>. Действие необратимо.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -252,12 +263,10 @@ export function DomainDiffPage() {
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{hasUpdates || (applyPrunes && hasPrunes)
|
||||
? "Готово к применению"
|
||||
: "Изменений для применения нет"}
|
||||
{hasSelection ? "Готово к применению" : "Изменений для применения нет"}
|
||||
</span>
|
||||
)}
|
||||
<Button onClick={onApply} disabled={apply.isPending}>
|
||||
<Button onClick={onApply} disabled={apply.isPending || !hasSelection}>
|
||||
{apply.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" strokeWidth={1.75} />
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user