feat(web): customs section with mark and unmark actions

This commit is contained in:
2026-08-19 18:22:32 +07:00
parent 404f869d42
commit e00648baf7
4 changed files with 129 additions and 6 deletions
+55 -5
View File
@@ -1,11 +1,12 @@
import type { ReactNode } from "react"
import { ArrowRight, CircleCheck, Lock, Pencil, Trash2 } from "lucide-react"
import { ArrowRight, BookmarkCheck, CircleCheck, Lock, Pencil, Trash2, Undo2 } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/lib/utils"
import type { ChangesetResponse, RecordView } from "@/api/types"
type Tone = "update" | "delete" | "readonly"
type Tone = "update" | "delete" | "custom" | "readonly"
const TONE_META: Record<
Tone,
@@ -25,6 +26,13 @@ const TONE_META: Record<
dot: "var(--diff-delete)",
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
},
custom: {
label: "Customs",
empty: "Нет записей, помеченных как осознанные.",
icon: BookmarkCheck,
dot: "var(--diff-custom)",
ring: "ring-[color-mix(in_oklch,var(--diff-custom),transparent_80%)]",
},
readonly: {
label: "Read-only",
empty: "Нет read-only записей.",
@@ -46,11 +54,13 @@ function RecordRow({
tone,
checked,
onToggle,
action,
}: {
record: RecordView
tone: Tone
checked?: boolean
onToggle?: (key: string) => void
action?: ReactNode
}) {
const meta = TONE_META[tone]
const showArrow = tone !== "delete"
@@ -99,6 +109,8 @@ function RecordRow({
read-only
</Badge>
)}
{action}
</div>
{/* Values line: plain block-level text (not flex) so a long
@@ -137,18 +149,21 @@ function Section({
selected,
onToggle,
onToggleAll,
renderAction,
}: {
tone: Tone
records: RecordView[]
selected?: Set<string>
onToggle?: (key: string) => void
onToggleAll?: (checked: boolean) => void
renderAction?: (record: RecordView) => ReactNode
}) {
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
// Read-only (NS/SOA) and custom records are never selectable — only
// update/delete sections receive selection props from DiffView.
const selectable =
tone !== "readonly" && tone !== "custom" && !!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
@@ -191,6 +206,7 @@ function Section({
tone={tone}
checked={selectable ? selected!.has(record.key) : undefined}
onToggle={selectable ? onToggle : undefined}
action={renderAction?.(record)}
/>
))}
</div>
@@ -207,6 +223,8 @@ export function DiffView({
onTogglePrune,
onToggleAllUpdates,
onToggleAllPrunes,
onMarkCustom,
onUnmarkCustom,
footerExtra,
}: {
changeset: ChangesetResponse
@@ -216,6 +234,8 @@ export function DiffView({
onTogglePrune: (key: string) => void
onToggleAllUpdates: (checked: boolean) => void
onToggleAllPrunes: (checked: boolean) => void
onMarkCustom?: (key: string) => void
onUnmarkCustom?: (key: string) => void
footerExtra?: ReactNode
}) {
// Defensive: a field may arrive as null (e.g. a nil slice from an older
@@ -235,6 +255,36 @@ export function DiffView({
selected={selectedPrunes}
onToggle={onTogglePrune}
onToggleAll={onToggleAllPrunes}
renderAction={
onMarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`В customs ${record.type} ${record.name}`}
onClick={() => onMarkCustom(record.key)}
>
<BookmarkCheck className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section
tone="custom"
records={changeset.customs ?? []}
renderAction={
onUnmarkCustom &&
((record) => (
<Button
variant="ghost"
size="icon-sm"
aria-label={`Вернуть в дифф ${record.type} ${record.name}`}
onClick={() => onUnmarkCustom(record.key)}
>
<Undo2 className="size-3.5" strokeWidth={1.75} />
</Button>
))
}
/>
<Section tone="readonly" records={changeset.readOnly ?? []} />
+3
View File
@@ -43,6 +43,7 @@
--color-diff-delete: var(--diff-delete);
--color-diff-insync: var(--diff-insync);
--color-diff-readonly: var(--diff-readonly);
--color-diff-custom: var(--diff-custom);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
@@ -96,6 +97,7 @@
--diff-delete: oklch(0.68 0.19 20); /* rose */
--diff-insync: oklch(0.55 0.02 260); /* muted */
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
--diff-custom: oklch(0.62 0.11 250); /* muted blue */
}
/* "Refined technical console" — dark by default (html.dark). Cool slate
@@ -139,6 +141,7 @@
--diff-delete: oklch(0.68 0.19 20);
--diff-insync: oklch(0.55 0.02 258);
--diff-readonly: oklch(0.42 0.014 258);
--diff-custom: oklch(0.62 0.11 250);
}
@layer base {
+59 -1
View File
@@ -1,4 +1,4 @@
import { render, screen, waitFor } from "@testing-library/react"
import { render, screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { MemoryRouter, Routes, Route } from "react-router-dom"
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
@@ -173,3 +173,61 @@ test("создание шаблона из зоны вызывает templateFro
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
})
test("кнопка «В customs» на строке prune вызывает api.addCustom с ключом записи", async () => {
const addSpy = vi.spyOn(api, "addCustom").mockResolvedValue(undefined)
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: false },
],
customs: [],
readOnly: [],
inSyncCount: 0,
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /в customs cname admin\.example\.com\./i }))
await waitFor(() =>
expect(addSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "CNAME admin.example.com."),
)
})
test("кнопка «Вернуть в дифф» на строке custom вызывает api.removeCustom", async () => {
const removeSpy = vi.spyOn(api, "removeCustom").mockResolvedValue(undefined)
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [],
customs: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
],
readOnly: [],
inSyncCount: 0,
})
const user = userEvent.setup()
renderPage()
await user.click(await screen.findByRole("button", { name: /вернуть в дифф cname admin\.example\.com\./i }))
await waitFor(() =>
expect(removeSpy).toHaveBeenCalledWith(PROJECT_ID, "d1", "CNAME admin.example.com."),
)
})
test("в секции customs нет чекбоксов — такие записи не применяются", async () => {
vi.spyOn(api, "checkDomain").mockResolvedValue({
updates: [],
prunes: [],
customs: [
{ key: "CNAME admin.example.com.", kind: "delete", type: "CNAME", name: "admin.example.com.", actual: ["example.com."], readOnly: false, custom: true },
],
readOnly: [],
inSyncCount: 0,
})
renderPage()
const section = await screen.findByRole("region", { name: /customs/i })
expect(within(section).queryByRole("checkbox")).toBeNull()
})
+12
View File
@@ -13,10 +13,12 @@ import {
TableRow,
} from "@/components/ui/table"
import {
useAddCustom,
useApplyDomain,
useCheckDomain,
useCreateTemplateFromZone,
useDomains,
useRemoveCustom,
useZoneRecords,
} from "@/hooks/useApi"
import { cn } from "@/lib/utils"
@@ -29,6 +31,8 @@ export function DomainDiffPage() {
const check = useCheckDomain(id, hasTemplate)
const apply = useApplyDomain(id)
const addCustom = useAddCustom(id)
const removeCustom = useRemoveCustom(id)
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
// недостоверно (false по умолчанию из-за domain === undefined) — не
// дёргаем provider-запрос записей зоны, пока не будет точно известно
@@ -234,6 +238,8 @@ export function DomainDiffPage() {
onTogglePrune={togglePrune}
onToggleAllUpdates={toggleAllUpdates}
onToggleAllPrunes={toggleAllPrunes}
onMarkCustom={(key) => addCustom.mutate(key)}
onUnmarkCustom={(key) => removeCustom.mutate(key)}
/>
<div className="flex flex-col gap-3 rounded-xl border border-border bg-card/60 p-4">
@@ -275,6 +281,12 @@ export function DomainDiffPage() {
Apply
</Button>
</div>
{(addCustom.isError || removeCustom.isError) && (
<span role="alert" className="font-dns text-xs text-destructive">
{(addCustom.error ?? removeCustom.error)?.message}
</span>
)}
</div>
</>
)}