feat(web): customs section with mark and unmark actions
This commit is contained in:
@@ -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 ?? []} />
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user