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:
@@ -1,6 +1,7 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ChangesetResponse, RecordView } from "@/api/types"
|
||||
|
||||
@@ -40,9 +41,23 @@ function Values({ values }: { values?: string[] }) {
|
||||
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 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 (
|
||||
<div
|
||||
@@ -52,9 +67,18 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
||||
)}
|
||||
style={{ borderLeftColor: meta.dot }}
|
||||
>
|
||||
{/* Top line: type badge, name, read-only flag — always single-line,
|
||||
never affected by how long the record values are. */}
|
||||
{/* Top line: (optional) checkbox, type badge, name, read-only flag —
|
||||
always single-line, never affected by how long the record values are. */}
|
||||
<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
|
||||
variant="outline"
|
||||
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
|
||||
width instead of stretching it — a flex item's content can
|
||||
otherwise refuse to shrink below its intrinsic width. Indented
|
||||
to align under the name (badge width + gap). */}
|
||||
<div className="font-dns hidden pl-14 text-xs leading-relaxed break-all text-muted-foreground sm:block">
|
||||
to align under the name (badge width + gap, plus checkbox width +
|
||||
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} />
|
||||
{showArrow && (
|
||||
<>
|
||||
@@ -104,16 +134,36 @@ function RecordRow({ record, tone }: { record: RecordView; tone: Tone }) {
|
||||
function Section({
|
||||
tone,
|
||||
records,
|
||||
selected,
|
||||
onToggle,
|
||||
onToggleAll,
|
||||
}: {
|
||||
tone: Tone
|
||||
records: RecordView[]
|
||||
selected?: Set<string>
|
||||
onToggle?: (key: string) => void
|
||||
onToggleAll?: (checked: boolean) => void
|
||||
}) {
|
||||
const meta = TONE_META[tone]
|
||||
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 (
|
||||
<section aria-label={meta.label} className="flex flex-col gap-2">
|
||||
<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 }} />
|
||||
<h2 className="text-xs font-semibold tracking-wide text-foreground uppercase">
|
||||
{meta.label}
|
||||
@@ -134,8 +184,14 @@ function Section({
|
||||
meta.ring,
|
||||
)}
|
||||
>
|
||||
{records.map((record, i) => (
|
||||
<RecordRow key={`${record.type}-${record.name}-${i}`} record={record} tone={tone} />
|
||||
{records.map((record) => (
|
||||
<RecordRow
|
||||
key={record.key}
|
||||
record={record}
|
||||
tone={tone}
|
||||
checked={selectable ? selected!.has(record.key) : undefined}
|
||||
onToggle={selectable ? onToggle : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -145,17 +201,41 @@ function Section({
|
||||
|
||||
export function DiffView({
|
||||
changeset,
|
||||
selectedUpdates,
|
||||
selectedPrunes,
|
||||
onToggleUpdate,
|
||||
onTogglePrune,
|
||||
onToggleAllUpdates,
|
||||
onToggleAllPrunes,
|
||||
footerExtra,
|
||||
}: {
|
||||
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
|
||||
}) {
|
||||
// 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.
|
||||
return (
|
||||
<div className="flex flex-col gap-6">
|
||||
<Section tone="update" records={changeset.updates ?? []} />
|
||||
<Section tone="delete" records={changeset.prunes ?? []} />
|
||||
<Section
|
||||
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 ?? []} />
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border pt-4">
|
||||
|
||||
Reference in New Issue
Block a user