fix(dashboard): batch delete no longer toasts failure after success (#12711)

Validado numa worktree combinada com as 16 PRs desta leva sobre `release/v3.8.51`: typecheck:core limpo, check-file-size e check-changelog-integrity OK, complexity 2788/3218 e cognitive 1261/1437 (ambos sob a baseline), ESLint 0 erros nos 152 arquivos alterados, 771 testes unitários focados, 49 de integração e a suíte vitest:ui completa (2149) verdes.

Além do bug do toast, esta PR foi a que derrubou os três base-reds vivos do tip: o fragmento `changelog.d/fixes/reset-aware-model-family.md` sem o `- ` inicial, o registro do `tests/unit/reset-aware-request-scope-12600.test.ts` no `stryker.conf.json` e o `TS2554` do glm. O `check-changelog-integrity` voltou a passar aqui por causa dela.

O diagnóstico do MouseEvent é o que dá o valor: `onConfirm` chegava como handler de clique nativo e `handleBatchDeleteConfirm` tratava qualquer primeiro argumento truthy como callback. O cinto (`typeof`) e o suspensório (o wrap no ConfirmModal) juntos estão certos — só um dos dois deixaria a porta aberta para o próximo caller.
This commit is contained in:
Bob.Hou
2026-09-07 07:56:36 -04:00
committed by GitHub
parent d857bd053a
commit 25bc16d87e
6 changed files with 167 additions and 4 deletions

View File

@@ -0,0 +1,2 @@
- **fix(dashboard):** batch-deleting provider keys no longer toasts failure after a successful delete when the confirm button's click event is forwarded as `onAfter` ([#12711](https://github.com/diegosouzapw/OmniRoute/pull/12711))
- **fix(glm):** drop the extra 16th argument to `createSSETransformStreamWithLogger` that TypeScript rejected (TS2554) and that never reached the TransformStream

View File

@@ -1 +1 @@
Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider.
- Keep Antigravity Gemini usable when the same connection's Claude weekly quota is empty; generic quota cache stays per-connection for every other provider.

View File

@@ -248,7 +248,7 @@ export interface UseProviderConnectionsReturn {
export function useProviderConnections(
providerId: string,
isCompatible: boolean,
isSearchProvider: boolean
_isSearchProvider: boolean
): UseProviderConnectionsReturn {
const t = useTranslations("providers");
const notify = useNotificationStore();
@@ -844,7 +844,8 @@ export function useProviderConnections(
setSelectedIds(new Set());
await fetchConnections();
notify.success(t("batchDeleteSuccess", { count }));
if (onAfter) await onAfter();
// ConfirmModal's onClick forwards a MouseEvent; only a real callback runs.
if (typeof onAfter === "function") await onAfter();
} else {
const data = await res.json();
notify.error(data.error || providerText(t, "batchDeleteFailed", "Batch delete failed"));

View File

@@ -275,7 +275,7 @@ export function ConfirmModal({
<Button variant="ghost" onClick={onClose} disabled={loading}>
{resolvedCancelText}
</Button>
<Button variant={variant} onClick={onConfirm} loading={loading}>
<Button variant={variant} onClick={() => void onConfirm()} loading={loading}>
{resolvedConfirmText}
</Button>
</>

View File

@@ -349,6 +349,7 @@
"tests/unit/repro-antigravity-404-family-cooldown-hijack.test.ts",
"tests/unit/repro-combo-persisted-cooldown-preskip.test.ts",
"tests/unit/repro-glm-iso-reset-24h-cap.test.ts",
"tests/unit/reset-aware-request-scope-12600.test.ts",
"tests/unit/resilience-connections.test.ts",
"tests/unit/responses-handler.test.ts",
"tests/unit/responses-passthrough-openai-compatible.test.ts",

View File

@@ -0,0 +1,159 @@
// @vitest-environment jsdom
/**
* ConfirmModal wires onConfirm to <Button onClick={onConfirm}>. Native
* buttons pass a MouseEvent. handleBatchDeleteConfirm's optional onAfter
* used to treat any truthy first arg as a post-delete callback, so the
* click event ran as `await onAfter()` after the success toast and the
* catch block fired a second "batch delete failed" toast.
*/
import React, { act, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("next/navigation", () => ({
useParams: () => ({ id: "moonshot-native" }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => "/providers/moonshot-native",
}));
vi.mock("next-intl", () => ({
useTranslations: () => (key: string, values?: Record<string, unknown>) => {
if (values) {
return Object.entries(values).reduce(
(acc, [k, v]) => acc.replace(`{${k}}`, String(v)),
key
);
}
return key;
},
}));
const notify = {
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
warning: vi.fn(),
};
vi.mock("@/store/notificationStore", () => ({
useNotificationStore: () => notify,
}));
const CONNECTIONS = [
{ id: "conn-a", provider: "moonshot-native", name: "Key A" },
{ id: "conn-b", provider: "moonshot-native", name: "Key B" },
];
function jsonResponse(status: number, body: unknown) {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
text: async () => JSON.stringify(body),
headers: { get: () => null },
} as Response;
}
function installFetchMock() {
const fn = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
const method = (init?.method || "GET").toUpperCase();
if ((url === "/api/providers" || url.startsWith("/api/providers?")) && method === "GET") {
return jsonResponse(200, { connections: CONNECTIONS });
}
if (url === "/api/provider-nodes" && method === "GET") {
return jsonResponse(200, { nodes: [] });
}
if (url === "/api/providers" && method === "DELETE") {
return jsonResponse(200, { message: "Deleted 2 connection(s)", deleted: 2 });
}
return jsonResponse(200, {});
});
vi.stubGlobal("fetch", fn);
return fn;
}
const { useProviderConnections } =
await import("@/app/(dashboard)/dashboard/providers/[id]/hooks/useProviderConnections");
type HookResult = ReturnType<typeof useProviderConnections>;
describe("useProviderConnections — batch delete click event must not toast failure after success", () => {
let container: HTMLElement;
let root: ReturnType<typeof createRoot>;
beforeEach(() => {
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT =
true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
notify.success.mockClear();
notify.error.mockClear();
notify.info.mockClear();
notify.warning.mockClear();
});
afterEach(() => {
act(() => {
root.unmount();
});
container.remove();
vi.unstubAllGlobals();
});
async function mountHook() {
let result: HookResult | null = null;
function TestWrapper() {
const hookResult = useProviderConnections("moonshot-native", false, true);
useEffect(() => {
result = hookResult;
}, [hookResult]);
return <span />;
}
await act(async () => {
root.render(<TestWrapper />);
});
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
return () => result as HookResult;
}
it("does not toast batchDeleteNetworkError when ConfirmModal forwards the click event", async () => {
installFetchMock();
const getHook = await mountHook();
await act(async () => {
getHook().handleToggleSelectAll();
});
expect(getHook().selectedIds.size).toBe(2);
const clickEvent = { type: "click", preventDefault() {} } as unknown as MouseEvent;
await act(async () => {
await getHook().handleBatchDeleteConfirm(clickEvent as never);
});
expect(notify.success).toHaveBeenCalledTimes(1);
expect(notify.error).not.toHaveBeenCalled();
});
it("still runs a real onAfter callback after a successful delete", async () => {
installFetchMock();
const getHook = await mountHook();
const onAfter = vi.fn(async () => {});
await act(async () => {
getHook().handleToggleSelectAll();
});
await act(async () => {
await getHook().handleBatchDeleteConfirm(onAfter);
});
expect(onAfter).toHaveBeenCalledTimes(1);
expect(notify.success).toHaveBeenCalledTimes(1);
expect(notify.error).not.toHaveBeenCalled();
});
});