feat(ui): cut the organizations page over to the /ui/organizations path route (#30336)

* feat(ui): cut the organizations page over to the /ui/organizations path route

OrganizationsTable now owns its data through React Query instead of lifted
shell state: useOrganizations (extended with optional org_id/org_alias
filters) replaces the organizations/setOrganizations prop pair, and a new
useUserModels hook replaces the userModels prop. Create and delete
invalidate the organizations list queries rather than refetching into a
parent setter. The dead currentOrg and guardrailsList props are removed.

The shell keeps its own fetchOrganizations call because the teams and
api-keys arms still read the lifted organizations state; the userModels
state had no remaining readers and is deleted.

* fix(ui): seed organization detail initialData from any cached list, not just the unfiltered one

useOrganization's initialData only read organizationKeys.list({}), so on a
session that only ever fetched a filtered organization list the detail view
fell back to a loading state and a redundant info call. Scan every cached
list variant via the lists() prefix instead, with regression tests covering
the filtered-cache hit and the no-cache fallthrough.
This commit is contained in:
ryan-crabbe-berri 2026-06-13 14:04:04 -07:00 committed by GitHub
parent 2fad75ffda
commit 685ec00afd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 247 additions and 80 deletions

View file

@ -40,6 +40,7 @@ export const MIGRATED_E2E_PAGES: Record<string, string> = {
agents: "agents",
"router-settings": "router-settings",
users: "users",
organizations: "organizations",
};
export const MIGRATED_E2E_SEGMENTS: string[] = [...new Set(Object.values(MIGRATED_E2E_PAGES))];

View file

@ -8,6 +8,7 @@ import {
useModelHub,
useModelsInfo,
useSelectedTeamModels,
useUserModels,
type AllProxyModelsResponse,
type PaginatedModelInfoResponse,
type ProxyModel,
@ -480,6 +481,70 @@ describe("useAllProxyModels", () => {
});
});
describe("useUserModels", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("maps the available-models response to a list of model ids", async () => {
(modelAvailableCall as any).mockResolvedValue({
data: [{ id: "gpt-4" }, { id: "claude-3-opus" }],
});
const { result } = renderHook(() => useUserModels(), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(result.current.data).toEqual(["gpt-4", "claude-3-opus"]);
expect(modelAvailableCall).toHaveBeenCalledWith("test-access-token", "test-user-id", "Admin");
expect(modelAvailableCall).toHaveBeenCalledTimes(1);
});
it("should not execute query when accessToken is missing", () => {
mockUseAuthorized.mockReturnValue({
accessToken: null,
userId: "test-user-id",
userRole: "Admin",
token: null,
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
const { result } = renderHook(() => useUserModels(), { wrapper });
expect(result.current.isFetched).toBe(false);
expect(modelAvailableCall).not.toHaveBeenCalled();
});
});
describe("useSelectedTeamModels", () => {
let queryClient: QueryClient;

View file

@ -1,4 +1,4 @@
import { useQuery, useInfiniteQuery } from "@tanstack/react-query";
import { useQuery, useInfiniteQuery, UseQueryResult } from "@tanstack/react-query";
import { createQueryKeys } from "../common/queryKeysFactory";
import { modelInfoCall, modelHubCall, modelAvailableCall } from "@/components/networking";
import useAuthorized from "../useAuthorized";
@ -27,6 +27,7 @@ const modelHubKeys = createQueryKeys("modelHub");
const allProxyModelsKeys = createQueryKeys("allProxyModels");
const selectedTeamModelsKeys = createQueryKeys("selectedTeamModels");
const infiniteModelKeys = createQueryKeys("infiniteModels");
const userModelsKeys = createQueryKeys("userModels");
export const useModelsInfo = (
page: number = 1,
@ -76,6 +77,18 @@ export const useAllProxyModels = () => {
});
};
export const useUserModels = (): UseQueryResult<string[]> => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<string[]>({
queryKey: userModelsKeys.list({}),
queryFn: async () => {
const response = await modelAvailableCall(accessToken!, userId!, userRole!);
return response["data"].map((model: { id: string }) => model.id);
},
enabled: Boolean(accessToken && userId && userRole),
});
};
export const useSelectedTeamModels = (teamID: string | null) => {
const { accessToken, userId, userRole } = useAuthorized();
return useQuery<AllProxyModelsResponse>({

View file

@ -2,13 +2,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderHook, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import React, { ReactNode } from "react";
import { useOrganizations } from "./useOrganizations";
import { organizationListCall } from "@/components/networking";
import { organizationKeys, useOrganization, useOrganizations } from "./useOrganizations";
import { organizationInfoCall, organizationListCall } from "@/components/networking";
import type { Organization } from "@/components/networking";
// Mock the networking function
vi.mock("@/components/networking", () => ({
organizationListCall: vi.fn(),
organizationInfoCall: vi.fn(),
}));
// Mock useAuthorized hook - we can override this in individual tests
@ -107,7 +108,7 @@ describe("useOrganizations", () => {
expect(result.current.data).toEqual(mockOrganizations);
expect(result.current.error).toBeNull();
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(1);
});
@ -131,10 +132,47 @@ describe("useOrganizations", () => {
expect(result.current.error).toEqual(testError);
expect(result.current.data).toBeUndefined();
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(1);
});
it("passes org_id and org_alias filters to organizationListCall and caches separately from the unfiltered list", async () => {
(organizationListCall as any).mockResolvedValue(mockOrganizations);
const { result } = renderHook(() => useOrganizations({ org_id: "org-1", org_alias: "Test Organization 1" }), {
wrapper,
});
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", "org-1", "Test Organization 1");
(organizationListCall as any).mockResolvedValue([]);
const { result: unfiltered } = renderHook(() => useOrganizations(), { wrapper });
await waitFor(() => {
expect(unfiltered.current.isSuccess).toBe(true);
});
expect(organizationListCall).toHaveBeenLastCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(2);
});
it("treats empty-string filters as no filters, writing to the unfiltered cache entry", async () => {
(organizationListCall as any).mockResolvedValue(mockOrganizations);
const { result } = renderHook(() => useOrganizations({ org_id: "", org_alias: "" }), { wrapper });
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(queryClient.getQueryData(organizationKeys.list({}))).toEqual(mockOrganizations);
});
it("should not execute query when accessToken is missing", async () => {
// Mock missing accessToken
mockUseAuthorized.mockReturnValue({
@ -243,7 +281,7 @@ describe("useOrganizations", () => {
expect(result.current.isLoading).toBe(false);
});
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
expect(organizationListCall).toHaveBeenCalledTimes(1);
});
@ -260,7 +298,7 @@ describe("useOrganizations", () => {
});
expect(result.current.data).toEqual([]);
expect(organizationListCall).toHaveBeenCalledWith("test-access-token");
expect(organizationListCall).toHaveBeenCalledWith("test-access-token", null, null);
});
it("should handle network timeout error", async () => {
@ -280,3 +318,58 @@ describe("useOrganizations", () => {
expect(result.current.data).toBeUndefined();
});
});
describe("useOrganization", () => {
let queryClient: QueryClient;
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
vi.clearAllMocks();
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId: "test-user-id",
userRole: "Admin",
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
});
const wrapper = ({ children }: { children: ReactNode }) =>
React.createElement(QueryClientProvider, { client: queryClient }, children);
it("seeds initialData from a filtered list cache entry so the detail renders without a loading state", () => {
(organizationInfoCall as any).mockResolvedValue(mockOrganizations[1]);
// Only a filtered list was ever fetched; the unfiltered list({}) entry stays empty.
queryClient.setQueryData(organizationKeys.list({ filters: { org_id: "org-2" } }), [mockOrganizations[1]]);
const { result } = renderHook(() => useOrganization("org-2"), { wrapper });
// initialData found org-2 in the filtered cache, so data is present on the first render.
expect(result.current.data).toEqual(mockOrganizations[1]);
expect(result.current.isLoading).toBe(false);
});
it("falls through to the detail API call when no cached list contains the organization", async () => {
(organizationInfoCall as any).mockResolvedValue(mockOrganizations[0]);
queryClient.setQueryData(organizationKeys.list({ filters: { org_id: "org-2" } }), [mockOrganizations[1]]);
const { result } = renderHook(() => useOrganization("org-1"), { wrapper });
// org-1 is in no cached list, so there is no initialData and it loads via the detail API.
expect(result.current.data).toBeUndefined();
await waitFor(() => {
expect(result.current.isSuccess).toBe(true);
});
expect(organizationInfoCall).toHaveBeenCalledWith("test-access-token", "org-1");
expect(result.current.data).toEqual(mockOrganizations[0]);
});
});

View file

@ -4,11 +4,23 @@ import { useQuery, useQueryClient, UseQueryResult } from "@tanstack/react-query"
import { createQueryKeys } from "../common/queryKeysFactory";
export const organizationKeys = createQueryKeys("organizations");
export const useOrganizations = (): UseQueryResult<Organization[]> => {
export interface OrganizationListFilters {
org_id?: string | null;
org_alias?: string | null;
}
export const useOrganizations = (filters?: OrganizationListFilters): UseQueryResult<Organization[]> => {
const { accessToken, userId, userRole } = useAuthorized();
const orgId = filters?.org_id || null;
const orgAlias = filters?.org_alias || null;
return useQuery<Organization[]>({
queryKey: organizationKeys.list({}),
queryFn: async () => await organizationListCall(accessToken!),
queryKey: organizationKeys.list(
orgId || orgAlias
? { filters: { ...(orgId && { org_id: orgId }), ...(orgAlias && { org_alias: orgAlias }) } }
: {},
),
queryFn: async () => await organizationListCall(accessToken!, orgId, orgAlias),
enabled: Boolean(accessToken && userId && userRole),
});
};
@ -31,9 +43,10 @@ export const useOrganization = (organizationID?: string) => {
initialData: () => {
if (!organizationID) return undefined;
const organizations = queryClient.getQueryData<Organization[]>(organizationKeys.list({}));
return organizations?.find((organization: Organization) => organization.organization_id === organizationID);
return queryClient
.getQueriesData<Organization[]>({ queryKey: organizationKeys.lists() })
.flatMap(([, organizations]) => organizations ?? [])
.find((organization) => organization.organization_id === organizationID);
},
});
};

View file

@ -0,0 +1,9 @@
"use client";
import OrganizationsTable from "@/components/organizations";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function OrganizationsPage() {
const { accessToken, userRole, premiumUser } = useAuthorized();
return <OrganizationsTable userRole={userRole ?? ""} accessToken={accessToken} premiumUser={premiumUser ?? false} />;
}

View file

@ -7,8 +7,8 @@ import LoadingScreen from "@/components/common_components/LoadingScreen";
import { Team } from "@/components/key_team_helpers/key_list";
import { Organization, proxyBaseUrl, getInProductNudgesCall } from "@/components/networking";
import OldTeams from "@/components/OldTeams";
import { fetchUserModels, CreateKeyPrefillData } from "@/components/organisms/create_key_button";
import Organizations, { fetchOrganizations } from "@/components/organizations";
import { CreateKeyPrefillData } from "@/components/organisms/create_key_button";
import { fetchOrganizations } from "@/components/organizations";
import PassThroughSettings from "@/components/pass_through_settings";
import { SurveyPrompt, SurveyModal, ClaudeCodePrompt, ClaudeCodeModal } from "@/components/survey";
import Usage from "@/components/usage";
@ -32,7 +32,6 @@ function CreateKeyPageContent() {
const [teams, setTeams] = useState<Team[] | null>(null);
const [keys, setKeys] = useState<null | any[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [userModels, setUserModels] = useState<string[]>([]);
const router = useRouter();
const searchParams = useSearchParams()!;
@ -172,9 +171,6 @@ function CreateKeyPageContent() {
}, [token]);
useEffect(() => {
if (accessToken && userID && userRole) {
fetchUserModels(userID, userRole, accessToken, setUserModels);
}
if (accessToken && userID && userRole) {
v2TeamListCall(accessToken, 1, 100, {
userID: userRole !== "Admin" && userRole !== "Admin Viewer" ? userID : null,
@ -333,15 +329,6 @@ function CreateKeyPageContent() {
premiumUser={premiumUser}
searchParams={searchParams}
/>
) : page == "organizations" ? (
<Organizations
organizations={organizations}
setOrganizations={setOrganizations}
userModels={userModels}
accessToken={accessToken}
userRole={userRole}
premiumUser={premiumUser}
/>
) : page == "pass-through-settings" ? (
<PassThroughSettings
userID={userID}

View file

@ -1,6 +1,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import React from "react";
import { describe, expect, it, vi } from "vitest";
vi.mock("./vector_store_management/VectorStoreSelector", () => ({
__esModule: true,
@ -10,22 +11,27 @@ vi.mock("./mcp_server_management/MCPServerSelector", () => ({
__esModule: true,
default: () => null,
}));
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
default: () => ({
accessToken: null,
userId: null,
userRole: null,
}),
}));
import OrganizationsTable from "./organizations";
const renderWithQueryClient = (ui: React.ReactElement) => {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>);
};
describe("OrganizationsTable", () => {
it("should render the OrganizationsTable component", () => {
const setOrganizations = vi.fn();
const { getByText } = render(
<OrganizationsTable
organizations={[]}
userRole="Admin"
userModels={[]}
accessToken={null}
setOrganizations={setOrganizations}
premiumUser={true}
/>,
const { getByText } = renderWithQueryClient(
<OrganizationsTable userRole="Admin" accessToken={null} premiumUser={true} />,
);
expect(getByText("+ Create New Organization")).toBeInTheDocument();

View file

@ -1,3 +1,5 @@
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useUserModels } from "@/app/(dashboard)/hooks/models/useModels";
import OrganizationFilters, { FilterState } from "@/app/(dashboard)/organizations/OrganizationFilters";
import { InfoCircleOutlined } from "@ant-design/icons";
import { ChevronDownIcon, ChevronRightIcon, RefreshIcon } from "@heroicons/react/outline";
@ -23,6 +25,7 @@ import {
TextInput,
} from "@tremor/react";
import { Form, Input, Modal, Select as Select2, Tooltip } from "antd";
import { useQueryClient } from "@tanstack/react-query";
import React, { useState } from "react";
import { formatNumberWithCommas } from "../utils/dataUtils";
import DeleteResourceModal from "./common_components/DeleteResourceModal";
@ -37,15 +40,10 @@ import NumericalInput from "./shared/numerical_input";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
interface OrganizationsTableProps {
organizations: Organization[];
userRole: string;
userModels: string[];
accessToken: string | null;
lastRefreshed?: string;
handleRefreshClick?: () => void;
currentOrg?: any;
guardrailsList?: string[];
setOrganizations: (organizations: Organization[]) => void;
premiumUser: boolean;
}
@ -60,15 +58,10 @@ export const fetchOrganizations = async (
};
const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
organizations,
userRole,
userModels,
accessToken,
lastRefreshed,
handleRefreshClick,
currentOrg,
guardrailsList = [],
setOrganizations,
premiumUser,
}) => {
const [selectedOrgId, setSelectedOrgId] = useState<string | null>(null);
@ -87,21 +80,14 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
sort_order: "desc",
});
const queryClient = useQueryClient();
const { data: organizations = [] } = useOrganizations({ org_id: filters.org_id, org_alias: filters.org_alias });
const { data: userModels = [] } = useUserModels();
const refetchOrganizations = () => queryClient.invalidateQueries({ queryKey: organizationKeys.lists() });
const handleFilterChange = (key: keyof FilterState, value: string) => {
const newFilters = { ...filters, [key]: value };
setFilters(newFilters);
// Call organizationListCall with the new filters
if (accessToken) {
organizationListCall(accessToken, newFilters.org_id || null, newFilters.org_alias || null)
.then((response) => {
if (response) {
setOrganizations(response);
}
})
.catch((error) => {
console.error("Error fetching organizations:", error);
});
}
setFilters((previousFilters) => ({ ...previousFilters, [key]: value }));
};
const handleFilterReset = () => {
@ -111,18 +97,6 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
sort_by: "created_at",
sort_order: "desc",
});
// Reset organizations list
if (accessToken) {
organizationListCall(accessToken, null, null)
.then((response) => {
if (response) {
setOrganizations(response);
}
})
.catch((error) => {
console.error("Error fetching organizations:", error);
});
}
};
const handleDelete = (orgId: string | null) => {
@ -142,8 +116,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
setIsDeleteModalOpen(false);
setOrgToDelete(null);
// Refresh organizations list
await fetchOrganizations(accessToken, setOrganizations, filters.org_id || null, filters.org_alias || null);
await refetchOrganizations();
} catch (error) {
console.error("Error deleting organization:", error);
} finally {
@ -189,8 +162,7 @@ const OrganizationsTable: React.FC<OrganizationsTableProps> = ({
NotificationsManager.success("Organization created successfully");
setIsOrgModalVisible(false);
form.resetFields();
// Refresh organizations list
fetchOrganizations(accessToken, setOrganizations, filters.org_id || null, filters.org_alias || null);
await refetchOrganizations();
} catch (error) {
console.error("Error creating organization:", error);
}

View file

@ -126,6 +126,13 @@ describe("migratedHref / legacyPageHref", () => {
expect(MIGRATED_PAGES.users).toBe("users");
});
it("maps the organizations id to its route", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.organizations).toBe("organizations");
});
});
describe("dev server (NODE_ENV=development)", () => {

View file

@ -43,6 +43,7 @@ export const MIGRATED_PAGES: Record<string, string> = {
agents: "agents",
"router-settings": "router-settings",
users: "users",
organizations: "organizations",
};
function uiBase(): string {