From 404f869d42cff5ac748512217398ffd051324b31 Mon Sep 17 00:00:00 2001 From: Vassiliy Yegorov Date: Wed, 19 Aug 2026 18:16:42 +0700 Subject: [PATCH] feat(web): api client and hooks for custom record marks --- web/src/api/client.ts | 10 +++++++++ web/src/api/types.ts | 2 ++ web/src/components/DiffView.test.tsx | 16 ++++++++------ web/src/hooks/useApi.ts | 30 +++++++++++++++++++++++++++ web/src/pages/DomainDiffPage.test.tsx | 14 +++++++------ 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/web/src/api/client.ts b/web/src/api/client.ts index f09822a..d731673 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -83,6 +83,16 @@ export const api = { req(projectPath(projectId, `/domains/${id}/check`)), applyDomain: (projectId: string, id: string, body: ApplyRequest) => req(projectPath(projectId, `/domains/${id}/apply`), { method: "POST", body: JSON.stringify(body) }), + addCustom: (projectId: string, id: string, key: string) => + req(projectPath(projectId, `/domains/${id}/customs`), { + method: "POST", + body: JSON.stringify({ key }), + }), + removeCustom: (projectId: string, id: string, key: string) => + req( + projectPath(projectId, `/domains/${id}/customs?key=${encodeURIComponent(key)}`), + { method: "DELETE" }, + ), domainHistory: (projectId: string, id: string) => req(projectPath(projectId, `/domains/${id}/history`)), diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 97c0ece..6cd1f1e 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -45,10 +45,12 @@ export interface RecordView { desired?: string[] actual?: string[] readOnly: boolean + custom: boolean // помечена оператором как живущая вне шаблона } export interface ChangesetResponse { updates: RecordView[] prunes: RecordView[] + customs: RecordView[] readOnly: RecordView[] inSyncCount: number } diff --git a/web/src/components/DiffView.test.tsx b/web/src/components/DiffView.test.tsx index 88b4a99..b60a67b 100644 --- a/web/src/components/DiffView.test.tsx +++ b/web/src/components/DiffView.test.tsx @@ -4,9 +4,10 @@ import { DiffView } from "./DiffView" import type { ChangesetResponse } from "@/api/types" const cs: ChangesetResponse = { - updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }], - prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false }], - readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true }], + updates: [{ key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false }], + prunes: [{ key: "A old.example.com.", kind: "delete", type: "A", name: "old.example.com.", actual: ["2.2.2.2"], readOnly: false, custom: false }], + customs: [], + readOnly: [{ key: "NS example.com.", kind: "update", type: "NS", name: "example.com.", desired: ["ns1."], actual: ["ns2."], readOnly: true, custom: false }], inSyncCount: 3, } @@ -60,9 +61,11 @@ test("renders a very long unbreakable value (DKIM key) without crashing", () => desired: [longValue], actual: [], readOnly: false, + custom: false, }, ], prunes: [], + customs: [], readOnly: [], inSyncCount: 0, } @@ -135,11 +138,12 @@ test("select-all header checkbox calls onToggleAllUpdates(true) when clicked whi test("select-all header checkbox is indeterminate when only some update rows are selected", () => { const csWithMultipleUpdates: ChangesetResponse = { updates: [ - { key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false }, - { key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false }, - { key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false }, + { key: "A www.example.com.", kind: "update", type: "A", name: "www.example.com.", desired: ["1.1.1.1"], actual: ["9.9.9.9"], readOnly: false, custom: false }, + { key: "A api.example.com.", kind: "update", type: "A", name: "api.example.com.", desired: ["1.1.1.2"], actual: ["9.9.9.8"], readOnly: false, custom: false }, + { key: "A cdn.example.com.", kind: "update", type: "A", name: "cdn.example.com.", desired: ["1.1.1.3"], actual: ["9.9.9.7"], readOnly: false, custom: false }, ], prunes: [], + customs: [], readOnly: [], inSyncCount: 0, } diff --git a/web/src/hooks/useApi.ts b/web/src/hooks/useApi.ts index 3250ec1..e150ed7 100644 --- a/web/src/hooks/useApi.ts +++ b/web/src/hooks/useApi.ts @@ -153,6 +153,36 @@ export function useApplyDomain(id: string) { onSuccess: () => qc.invalidateQueries({ queryKey: ["check", project?.id, id] }), }) } +export function useAddCustom(id: string) { + const { project } = useAuth() + const qc = useQueryClient() + return useMutation({ + mutationFn: (key: string) => { + const pid = requireProjectId(project) + return api.addCustom(pid, id, key) + }, + // Дифф пересчитывается на бэкенде — инвалидируем чек, а заодно статус + // домена в списке (custom-запись перестаёт быть дрифтом). + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["check", project?.id, id] }) + qc.invalidateQueries({ queryKey: ["domains", project?.id] }) + }, + }) +} +export function useRemoveCustom(id: string) { + const { project } = useAuth() + const qc = useQueryClient() + return useMutation({ + mutationFn: (key: string) => { + const pid = requireProjectId(project) + return api.removeCustom(pid, id, key) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ["check", project?.id, id] }) + qc.invalidateQueries({ queryKey: ["domains", project?.id] }) + }, + }) +} export function useDomainHistory(id: string) { const { project } = useAuth() return useQuery({ diff --git a/web/src/pages/DomainDiffPage.test.tsx b/web/src/pages/DomainDiffPage.test.tsx index 708cd3d..25d8516 100644 --- a/web/src/pages/DomainDiffPage.test.tsx +++ b/web/src/pages/DomainDiffPage.test.tsx @@ -43,11 +43,12 @@ beforeEach(() => { test("default selection: updates checked, prunes unchecked; apply sends only selected keys", async () => { vi.spyOn(api, "checkDomain").mockResolvedValue({ - updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }], - prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false }], + updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }], + prunes: [{ key: "A b.", kind: "delete", type: "A", name: "b.", actual: ["3"], readOnly: false, custom: false }], + customs: [], readOnly: [], inSyncCount: 0, }) - const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) + const applySpy = vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 }) const zoneRecordsSpy = vi.spyOn(api, "zoneRecords") const user = userEvent.setup() renderPage() @@ -78,11 +79,12 @@ test("default selection: updates checked, prunes unchecked; apply sends only sel test("deselecting all records disables Apply", async () => { vi.spyOn(api, "checkDomain").mockResolvedValue({ - updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false }], + updates: [{ key: "A a.", kind: "update", type: "A", name: "a.", desired: ["1"], actual: ["2"], readOnly: false, custom: false }], prunes: [], + customs: [], readOnly: [], inSyncCount: 0, }) - vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], readOnly: [], inSyncCount: 0 }) + vi.spyOn(api, "applyDomain").mockResolvedValue({ updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0 }) const user = userEvent.setup() renderPage() @@ -105,7 +107,7 @@ test("пока список доменов грузится — показан }), ) const checkSpy = vi.spyOn(api, "checkDomain").mockResolvedValue({ - updates: [], prunes: [], readOnly: [], inSyncCount: 0, + updates: [], prunes: [], customs: [], readOnly: [], inSyncCount: 0, }) const zoneRecordsSpy = vi.spyOn(api, "zoneRecords") renderPage()