fix(ui): add credential on LLM Credentials tab leaves Add Model dropdown stale

After a credential was added/updated/deleted in the "LLM Credentials" tab,
ModelsAndEndpointsView's credentialsList (passed to the "Add Model" dropdown)
was never refreshed because CredentialsPanel called refetch() on its own
observer only.

Replace with queryClient.invalidateQueries({ queryKey: credentialsKeys.all })
— the established cross-component invalidation pattern used throughout the
codebase (accessGroupKeys, projectKeys, cloudZeroSettingsKeys, etc.) — which
marks the shared React Query cache entry as stale and triggers a refetch for
all active subscribers, including ModelsAndEndpointsView.

Co-Authored-By: Claude Sonnet 4 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bytechoreographer 2026-04-16 14:02:43 +08:00
parent b8f7d61400
commit 9c1c86bd48
3 changed files with 65 additions and 12 deletions

View file

@ -3,7 +3,7 @@ import { useQuery } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
const credentialsKeys = createQueryKeys("credentials");
export const credentialsKeys = createQueryKeys("credentials");
export const useCredentials = () => {
const { accessToken } = useAuthorized();

View file

@ -1,4 +1,4 @@
import { CredentialItem } from "@/components/networking";
import { credentialCreateCall, CredentialItem } from "@/components/networking";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { UploadProps } from "antd/es/upload";
@ -16,6 +16,29 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
vi.mock("@/app/(dashboard)/hooks/credentials/useCredentials", () => ({
useCredentials: () => mockUseCredentials(),
credentialsKeys: { all: ["credentials"] },
}));
vi.mock("@/components/networking", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/components/networking")>();
return {
...actual,
credentialCreateCall: vi.fn(),
};
});
// Mock the modal to expose a direct submit trigger so tests don't need to
// interact with Ant Design form internals and multi-step validation.
vi.mock("./AddCredentialModal", () => ({
default: ({ open, onAddCredential }: any) =>
open ? (
<button
data-testid="mock-add-credential-submit"
onClick={() => onAddCredential({ credential_name: "test-cred", custom_llm_provider: "openai" })}
>
Submit Credential
</button>
) : null,
}));
const createQueryClient = () =>
@ -33,7 +56,6 @@ describe("CredentialsPanel", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseCredentials.mockReturnValue({
data: { credentials: [] },
refetch: vi.fn(),
});
render(
@ -57,7 +79,6 @@ describe("CredentialsPanel", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseCredentials.mockReturnValue({
data: { credentials },
refetch: vi.fn(),
});
render(
@ -73,7 +94,6 @@ describe("CredentialsPanel", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseCredentials.mockReturnValue({
data: { credentials: [] },
refetch: vi.fn(),
});
render(
@ -89,7 +109,6 @@ describe("CredentialsPanel", () => {
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseCredentials.mockReturnValue({
data: { credentials: [] },
refetch: vi.fn(),
});
render(
@ -105,7 +124,39 @@ describe("CredentialsPanel", () => {
});
await waitFor(() => {
expect(screen.getByText("Add New Credential")).toBeInTheDocument();
expect(screen.getByTestId("mock-add-credential-submit")).toBeInTheDocument();
});
});
it("should invalidate shared credentials cache after adding a credential", async () => {
const queryClient = createQueryClient();
const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries").mockResolvedValue();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token" });
mockUseCredentials.mockReturnValue({ data: { credentials: [] } });
(credentialCreateCall as ReturnType<typeof vi.fn>).mockResolvedValue({});
render(
<QueryClientProvider client={queryClient}>
<CredentialsPanel uploadProps={DEFAULT_UPLOAD_PROPS} />
</QueryClientProvider>,
);
// Open modal
act(() => {
fireEvent.click(screen.getByRole("button", { name: /add credential/i }));
});
// Trigger submission via the mocked modal's direct submit button
await waitFor(() => screen.getByTestId("mock-add-credential-submit"));
await act(async () => {
fireEvent.click(screen.getByTestId("mock-add-credential-submit"));
});
await waitFor(() => {
expect(invalidateSpy).toHaveBeenCalledWith(
expect.objectContaining({ queryKey: ["credentials"] }),
);
});
});
});

View file

@ -24,7 +24,8 @@ import DeleteResourceModal from "../common_components/DeleteResourceModal";
import NotificationsManager from "../molecules/notifications_manager";
import AddCredentialsTab from "./AddCredentialModal";
import EditCredentialsModal from "./EditCredentialModal";
import { useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { credentialsKeys, useCredentials } from "@/app/(dashboard)/hooks/credentials/useCredentials";
import { useQueryClient } from "@tanstack/react-query";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
interface CredentialsPanelProps {
uploadProps: UploadProps;
@ -32,7 +33,8 @@ interface CredentialsPanelProps {
const CredentialsPanel: React.FC<CredentialsPanelProps> = ({ uploadProps }) => {
const { accessToken } = useAuthorized();
const { data: credentialsResponse, refetch: refetchCredentials } = useCredentials();
const { data: credentialsResponse } = useCredentials();
const queryClient = useQueryClient();
const credentialList = credentialsResponse?.credentials || [];
const [isAddModalOpen, setIsAddModalOpen] = useState(false);
@ -64,7 +66,7 @@ const CredentialsPanel: React.FC<CredentialsPanelProps> = ({ uploadProps }) => {
await credentialUpdateCall(accessToken, values.credential_name, newCredential);
NotificationsManager.success("Credential updated successfully");
setIsUpdateModalOpen(false);
await refetchCredentials();
await queryClient.invalidateQueries({ queryKey: credentialsKeys.all });
};
const handleAddCredential = async (values: any) => {
@ -88,7 +90,7 @@ const CredentialsPanel: React.FC<CredentialsPanelProps> = ({ uploadProps }) => {
await credentialCreateCall(accessToken, newCredential);
NotificationsManager.success("Credential added successfully");
setIsAddModalOpen(false);
await refetchCredentials();
await queryClient.invalidateQueries({ queryKey: credentialsKeys.all });
};
const renderProviderBadge = (provider: string) => {
@ -115,7 +117,7 @@ const CredentialsPanel: React.FC<CredentialsPanelProps> = ({ uploadProps }) => {
try {
await credentialDeleteCall(accessToken, credentialToDelete.credential_name);
NotificationsManager.success("Credential deleted successfully");
await refetchCredentials();
await queryClient.invalidateQueries({ queryKey: credentialsKeys.all });
} catch (error) {
NotificationsManager.error("Failed to delete credential");
} finally {