feat(web): customs section with mark and unmark actions
This commit is contained in:
@@ -1,11 +1,12 @@
|
|||||||
import type { ReactNode } from "react"
|
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 { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import { Checkbox } from "@/components/ui/checkbox"
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { ChangesetResponse, RecordView } from "@/api/types"
|
import type { ChangesetResponse, RecordView } from "@/api/types"
|
||||||
|
|
||||||
type Tone = "update" | "delete" | "readonly"
|
type Tone = "update" | "delete" | "custom" | "readonly"
|
||||||
|
|
||||||
const TONE_META: Record<
|
const TONE_META: Record<
|
||||||
Tone,
|
Tone,
|
||||||
@@ -25,6 +26,13 @@ const TONE_META: Record<
|
|||||||
dot: "var(--diff-delete)",
|
dot: "var(--diff-delete)",
|
||||||
ring: "ring-[color-mix(in_oklch,var(--diff-delete),transparent_78%)]",
|
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: {
|
readonly: {
|
||||||
label: "Read-only",
|
label: "Read-only",
|
||||||
empty: "Нет read-only записей.",
|
empty: "Нет read-only записей.",
|
||||||
@@ -46,11 +54,13 @@ function RecordRow({
|
|||||||
tone,
|
tone,
|
||||||
checked,
|
checked,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
action,
|
||||||
}: {
|
}: {
|
||||||
record: RecordView
|
record: RecordView
|
||||||
tone: Tone
|
tone: Tone
|
||||||
checked?: boolean
|
checked?: boolean
|
||||||
onToggle?: (key: string) => void
|
onToggle?: (key: string) => void
|
||||||
|
action?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const showArrow = tone !== "delete"
|
const showArrow = tone !== "delete"
|
||||||
@@ -99,6 +109,8 @@ function RecordRow({
|
|||||||
read-only
|
read-only
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{action}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Values line: plain block-level text (not flex) so a long
|
{/* Values line: plain block-level text (not flex) so a long
|
||||||
@@ -137,18 +149,21 @@ function Section({
|
|||||||
selected,
|
selected,
|
||||||
onToggle,
|
onToggle,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
|
renderAction,
|
||||||
}: {
|
}: {
|
||||||
tone: Tone
|
tone: Tone
|
||||||
records: RecordView[]
|
records: RecordView[]
|
||||||
selected?: Set<string>
|
selected?: Set<string>
|
||||||
onToggle?: (key: string) => void
|
onToggle?: (key: string) => void
|
||||||
onToggleAll?: (checked: boolean) => void
|
onToggleAll?: (checked: boolean) => void
|
||||||
|
renderAction?: (record: RecordView) => ReactNode
|
||||||
}) {
|
}) {
|
||||||
const meta = TONE_META[tone]
|
const meta = TONE_META[tone]
|
||||||
const Icon = meta.icon
|
const Icon = meta.icon
|
||||||
// Read-only (NS/SOA) records are never selectable — only update/delete
|
// Read-only (NS/SOA) and custom records are never selectable — only
|
||||||
// sections receive selection props from DiffView.
|
// update/delete sections receive selection props from DiffView.
|
||||||
const selectable = tone !== "readonly" && !!selected && !!onToggle && !!onToggleAll
|
const selectable =
|
||||||
|
tone !== "readonly" && tone !== "custom" && !!selected && !!onToggle && !!onToggleAll
|
||||||
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
|
const allSelected = selectable && records.length > 0 && records.every((r) => selected!.has(r.key))
|
||||||
const someSelected = selectable && records.some((r) => selected!.has(r.key))
|
const someSelected = selectable && records.some((r) => selected!.has(r.key))
|
||||||
const indeterminate = someSelected && !allSelected
|
const indeterminate = someSelected && !allSelected
|
||||||
@@ -191,6 +206,7 @@ function Section({
|
|||||||
tone={tone}
|
tone={tone}
|
||||||
checked={selectable ? selected!.has(record.key) : undefined}
|
checked={selectable ? selected!.has(record.key) : undefined}
|
||||||
onToggle={selectable ? onToggle : undefined}
|
onToggle={selectable ? onToggle : undefined}
|
||||||
|
action={renderAction?.(record)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -207,6 +223,8 @@ export function DiffView({
|
|||||||
onTogglePrune,
|
onTogglePrune,
|
||||||
onToggleAllUpdates,
|
onToggleAllUpdates,
|
||||||
onToggleAllPrunes,
|
onToggleAllPrunes,
|
||||||
|
onMarkCustom,
|
||||||
|
onUnmarkCustom,
|
||||||
footerExtra,
|
footerExtra,
|
||||||
}: {
|
}: {
|
||||||
changeset: ChangesetResponse
|
changeset: ChangesetResponse
|
||||||
@@ -216,6 +234,8 @@ export function DiffView({
|
|||||||
onTogglePrune: (key: string) => void
|
onTogglePrune: (key: string) => void
|
||||||
onToggleAllUpdates: (checked: boolean) => void
|
onToggleAllUpdates: (checked: boolean) => void
|
||||||
onToggleAllPrunes: (checked: boolean) => void
|
onToggleAllPrunes: (checked: boolean) => void
|
||||||
|
onMarkCustom?: (key: string) => void
|
||||||
|
onUnmarkCustom?: (key: string) => void
|
||||||
footerExtra?: ReactNode
|
footerExtra?: ReactNode
|
||||||
}) {
|
}) {
|
||||||
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
// Defensive: a field may arrive as null (e.g. a nil slice from an older
|
||||||
@@ -235,6 +255,36 @@ export function DiffView({
|
|||||||
selected={selectedPrunes}
|
selected={selectedPrunes}
|
||||||
onToggle={onTogglePrune}
|
onToggle={onTogglePrune}
|
||||||
onToggleAll={onToggleAllPrunes}
|
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 ?? []} />
|
<Section tone="readonly" records={changeset.readOnly ?? []} />
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,7 @@
|
|||||||
--color-diff-delete: var(--diff-delete);
|
--color-diff-delete: var(--diff-delete);
|
||||||
--color-diff-insync: var(--diff-insync);
|
--color-diff-insync: var(--diff-insync);
|
||||||
--color-diff-readonly: var(--diff-readonly);
|
--color-diff-readonly: var(--diff-readonly);
|
||||||
|
--color-diff-custom: var(--diff-custom);
|
||||||
--radius-sm: calc(var(--radius) * 0.6);
|
--radius-sm: calc(var(--radius) * 0.6);
|
||||||
--radius-md: calc(var(--radius) * 0.8);
|
--radius-md: calc(var(--radius) * 0.8);
|
||||||
--radius-lg: var(--radius);
|
--radius-lg: var(--radius);
|
||||||
@@ -96,6 +97,7 @@
|
|||||||
--diff-delete: oklch(0.68 0.19 20); /* rose */
|
--diff-delete: oklch(0.68 0.19 20); /* rose */
|
||||||
--diff-insync: oklch(0.55 0.02 260); /* muted */
|
--diff-insync: oklch(0.55 0.02 260); /* muted */
|
||||||
--diff-readonly: oklch(0.5 0.02 260); /* dimmed */
|
--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
|
/* "Refined technical console" — dark by default (html.dark). Cool slate
|
||||||
@@ -139,6 +141,7 @@
|
|||||||
--diff-delete: oklch(0.68 0.19 20);
|
--diff-delete: oklch(0.68 0.19 20);
|
||||||
--diff-insync: oklch(0.55 0.02 258);
|
--diff-insync: oklch(0.55 0.02 258);
|
||||||
--diff-readonly: oklch(0.42 0.014 258);
|
--diff-readonly: oklch(0.42 0.014 258);
|
||||||
|
--diff-custom: oklch(0.62 0.11 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@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 userEvent from "@testing-library/user-event"
|
||||||
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
import { MemoryRouter, Routes, Route } from "react-router-dom"
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
|
||||||
@@ -173,3 +173,61 @@ test("создание шаблона из зоны вызывает templateFro
|
|||||||
|
|
||||||
await waitFor(() => expect(templateFromZoneSpy).toHaveBeenCalledWith(PROJECT_ID, "d1"))
|
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,
|
TableRow,
|
||||||
} from "@/components/ui/table"
|
} from "@/components/ui/table"
|
||||||
import {
|
import {
|
||||||
|
useAddCustom,
|
||||||
useApplyDomain,
|
useApplyDomain,
|
||||||
useCheckDomain,
|
useCheckDomain,
|
||||||
useCreateTemplateFromZone,
|
useCreateTemplateFromZone,
|
||||||
useDomains,
|
useDomains,
|
||||||
|
useRemoveCustom,
|
||||||
useZoneRecords,
|
useZoneRecords,
|
||||||
} from "@/hooks/useApi"
|
} from "@/hooks/useApi"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
@@ -29,6 +31,8 @@ export function DomainDiffPage() {
|
|||||||
|
|
||||||
const check = useCheckDomain(id, hasTemplate)
|
const check = useCheckDomain(id, hasTemplate)
|
||||||
const apply = useApplyDomain(id)
|
const apply = useApplyDomain(id)
|
||||||
|
const addCustom = useAddCustom(id)
|
||||||
|
const removeCustom = useRemoveCustom(id)
|
||||||
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
|
// Пока список доменов не загружен ИЛИ загрузка упала ошибкой, hasTemplate
|
||||||
// недостоверно (false по умолчанию из-за domain === undefined) — не
|
// недостоверно (false по умолчанию из-за domain === undefined) — не
|
||||||
// дёргаем provider-запрос записей зоны, пока не будет точно известно
|
// дёргаем provider-запрос записей зоны, пока не будет точно известно
|
||||||
@@ -234,6 +238,8 @@ export function DomainDiffPage() {
|
|||||||
onTogglePrune={togglePrune}
|
onTogglePrune={togglePrune}
|
||||||
onToggleAllUpdates={toggleAllUpdates}
|
onToggleAllUpdates={toggleAllUpdates}
|
||||||
onToggleAllPrunes={toggleAllPrunes}
|
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">
|
<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
|
Apply
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(addCustom.isError || removeCustom.isError) && (
|
||||||
|
<span role="alert" className="font-dns text-xs text-destructive">
|
||||||
|
{(addCustom.error ?? removeCustom.error)?.message}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user