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:
2026-07-05 15:20:09 +07:00
parent 0b26923586
commit 2f1f5311ad
6 changed files with 259 additions and 72 deletions
+52 -43
View File
@@ -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} />
) : (