diff --git a/web/src/components/DiffView.tsx b/web/src/components/DiffView.tsx index 463fa51..7671b8e 100644 --- a/web/src/components/DiffView.tsx +++ b/web/src/components/DiffView.tsx @@ -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 )} + + {action} {/* 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 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)} /> ))} @@ -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) => ( + + )) + } + /> +
( + + )) + } />
diff --git a/web/src/index.css b/web/src/index.css index 9f0905e..bcd7df4 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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 { diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 25d8516..930a61d 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -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() +}) diff --git a/web/src/pages/DomainDiffPage.tsx b/web/src/pages/DomainDiffPage.tsx index eac410f..c12fd8d 100644 --- a/web/src/pages/DomainDiffPage.tsx +++ b/web/src/pages/DomainDiffPage.tsx @@ -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)} />
@@ -275,6 +281,12 @@ export function DomainDiffPage() { Apply
+ + {(addCustom.isError || removeCustom.isError) && ( + + {(addCustom.error ?? removeCustom.error)?.message} + + )} )}