feat(ui): migrate projects and access-groups to path routes (#30226)

* feat(ui): cut projects and access-groups over to path routes

Same recipe as playground (#30185): MIGRATED_PAGES entries route the
sidebar and redirect the legacy ?page= URLs, the switch arms are
deleted, and the e2e fixture grows two entries. Both components were
already zero-prop and self-fetching via React Query hooks, so the
route wrappers are trivial.

* refactor(ui): move Projects and AccessGroups components into their route folders

Both folders were imported only by the legacy switch, so they colocate
wholesale under (dashboard)/{projects,access-groups}/components. Their
React Query hooks stay in the shared (dashboard)/hooks layer. eslint
suppressions are re-keyed to the new paths.

* test(ui): enable enable_projects_ui in e2e global setup

The projects migration smoke clicks the Projects sidebar link, which
only renders when the enterprise-gated enable_projects_ui setting is
on; the seeded e2e database starts with it off, so the locator timed
out in both e2e_ui_testing jobs. CI already launches the proxy with
LITELLM_LICENSE for premium UI coverage, so flip the setting in
globalSetup via the same /update/ui_settings call the admin UI toggle
makes, failing loudly if the PATCH is rejected.

* test(ui): use Playwright request context instead of raw fetch in global setup

The frontend lint bans raw fetch() outside src/lib/http/; the e2e
convention for proxy API calls is Playwright's APIRequestContext, as
in routerSettings.spec.ts.
This commit is contained in:
ryan-crabbe-berri 2026-06-11 13:20:21 -07:00 committed by GitHub
parent 530c0b2326
commit a2c916fb45
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
33 changed files with 71 additions and 32 deletions

View File

@ -11,13 +11,15 @@
* Keep this in lockstep with MIGRATED_PAGES in src/utils/migratedPages.ts.
* Pending (add as each PR lands): the leaf-pages batch
* (budgets, caching, cost-tracking, guardrails, guardrails-monitor, logs,
* mcp-servers, memory, policies, projects, prompts, search-tools, skills,
* mcp-servers, memory, policies, prompts, search-tools, skills,
* tag-management, tool-policies, transform-request, ui-theme, vector-stores,
* workflows, access-groups).
* workflows).
*/
export const MIGRATED_E2E_PAGES: Record<string, string> = {
api_ref: "api-reference",
"llm-playground": "playground",
projects: "projects",
"access-groups": "access-groups",
};
export const MIGRATED_E2E_SEGMENTS: string[] = [...new Set(Object.values(MIGRATED_E2E_PAGES))];

View File

@ -1,4 +1,4 @@
import { chromium, expect } from "@playwright/test";
import { chromium, expect, request } from "@playwright/test";
import { users, Role, STORAGE_PATHS } from "./fixtures/users";
import * as fs from "fs";
@ -6,6 +6,21 @@ async function globalSetup() {
const browser = await chromium.launch();
const rootPath = process.env.SERVER_ROOT_PATH ?? "";
// The Projects sidebar item is hidden unless the enterprise-gated
// enable_projects_ui setting is on, and the seeded DB starts with it off.
// The proxy runs with LITELLM_LICENSE in CI, so enable it the same way
// the admin UI toggle does; the projects migration smoke needs the link.
const masterKey = process.env.LITELLM_MASTER_KEY || "sk-1234";
const api = await request.newContext();
const settingsRes = await api.patch(`http://localhost:4000${rootPath}/update/ui_settings`, {
headers: { Authorization: `Bearer ${masterKey}` },
data: { enable_projects_ui: true },
});
if (!settingsRes.ok()) {
throw new Error(`Enabling enable_projects_ui failed (${settingsRes.status()}): ${await settingsRes.text()}`);
}
await api.dispose();
for (const role of Object.values(Role)) {
const { email, password } = users[role];
const storagePath = STORAGE_PATHS[role];

View File

@ -472,22 +472,22 @@
"count": 4
}
},
"src/components/Projects/ProjectDetailsPage.tsx": {
"src/app/(dashboard)/projects/components/ProjectDetailsPage.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Projects/ProjectKeysSection.tsx": {
"src/app/(dashboard)/projects/components/ProjectKeysSection.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}
},
"src/components/Projects/ProjectModals/ProjectBaseForm.tsx": {
"src/app/(dashboard)/projects/components/ProjectModals/ProjectBaseForm.tsx": {
"react-hooks/set-state-in-effect": {
"count": 2
}
},
"src/components/Projects/ProjectsPage.tsx": {
"src/app/(dashboard)/projects/components/ProjectsPage.tsx": {
"react-hooks/set-state-in-effect": {
"count": 1
}

View File

@ -3,7 +3,7 @@ import { AccessGroupResponse } from "@/app/(dashboard)/hooks/accessGroups/useAcc
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { renderWithProviders } from "../../../../../tests/test-utils";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
vi.mock("@/app/(dashboard)/hooks/accessGroups/useAccessGroupDetails");

View File

@ -17,7 +17,7 @@ import {
} from "antd";
import { ArrowLeftIcon, BotIcon, EditIcon, KeyIcon, LayersIcon, ServerIcon, UsersIcon } from "lucide-react";
import { useState } from "react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { AccessGroupEditModal } from "./AccessGroupsModal/AccessGroupEditModal";
const { Title, Text } = Typography;

View File

@ -65,7 +65,7 @@ vi.mock("./AccessGroupsModal/AccessGroupCreateModal", () => ({
) : null,
}));
vi.mock("../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({
vi.mock("@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton", () => ({
default: ({ variant, tooltipText, onClick }: { variant: string; tooltipText: string; onClick: () => void }) => (
<button data-testid={`action-button-${variant.toLowerCase()}`} aria-label={tooltipText} onClick={onClick}>
{variant}

View File

@ -13,12 +13,12 @@ import {
import { Button, Card, Flex, Input, Layout, Pagination, Space, Table, Tag, theme, Tooltip, Typography } from "antd";
import { BotIcon, LayersIcon, SearchIcon, ServerIcon } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import DeleteResourceModal from "../common_components/DeleteResourceModal";
import TableIconActionButton from "../common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import DeleteResourceModal from "@/components/common_components/DeleteResourceModal";
import TableIconActionButton from "@/components/common_components/IconActionButton/TableIconActionButtons/TableIconActionButton";
import {
SortState,
TableHeaderSortDropdown,
} from "../common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
} from "@/components/common_components/TableHeaderSortDropdown/TableHeaderSortDropdown";
import { AccessGroupDetail } from "./AccessGroupsDetailsPage";
import { AccessGroupCreateModal } from "./AccessGroupsModal/AccessGroupCreateModal";
import { AccessGroup } from "./types";

View File

@ -0,0 +1,9 @@
"use client";
import { AccessGroupsPage } from "./components/AccessGroupsPage";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function AccessGroups() {
useAuthorized();
return <AccessGroupsPage />;
}

View File

@ -34,8 +34,6 @@ import TransformRequestPanel from "@/components/transform_request";
import UIThemeSettings from "@/components/ui_theme_settings";
import Usage from "@/components/usage";
import UserDashboard from "@/components/user_dashboard";
import { AccessGroupsPage } from "@/components/AccessGroups/AccessGroupsPage";
import { ProjectsPage } from "@/components/Projects/ProjectsPage";
import VectorStoreManagement from "@/components/vector_store_management";
import ToolPoliciesView from "@/components/ToolPoliciesView";
import { MemoryView } from "@/components/MemoryView";
@ -448,10 +446,6 @@ function CreateKeyPageContent() {
<TagManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "skills" || page == "claude-code-plugins" ? (
<ClaudeCodePluginsPanel accessToken={accessToken} userRole={userRole} />
) : page == "access-groups" ? (
<AccessGroupsPage />
) : page == "projects" ? (
<ProjectsPage />
) : page == "vector-stores" ? (
<VectorStoreManagement accessToken={accessToken} userRole={userRole} userID={userID} />
) : page == "tool-policies" ? (

View File

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { ProjectDetail } from "./ProjectDetailsPage";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";

View File

@ -19,7 +19,7 @@ import { LoadingOutlined } from "@ant-design/icons";
import { BarChart } from "@tremor/react";
import { ArrowLeftIcon, DollarSignIcon, EditIcon, KeyIcon, UsersIcon } from "lucide-react";
import { useMemo, useState } from "react";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
import { EditProjectModal } from "./ProjectModals/EditProjectModal";
const { Title, Text } = Typography;

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { ProjectKeysSection } from "./ProjectKeysSection";
const mockUseKeys = vi.fn();

View File

@ -1,5 +1,5 @@
import { describe, it, expect, vi } from "vitest";
import { renderWithProviders, screen } from "../../../tests/test-utils";
import { renderWithProviders, screen } from "../../../../../tests/test-utils";
import { ProjectKeysTable } from "./ProjectKeysTable";
import { KeyResponse } from "@/components/key_team_helpers/key_list";

View File

@ -2,7 +2,7 @@ import { KeyResponse } from "@/components/key_team_helpers/key_list";
import { Empty, Table, Tooltip } from "antd";
import type { ColumnsType } from "antd/es/table";
import type { SpinProps } from "antd";
import DefaultProxyAdminTag from "../common_components/DefaultProxyAdminTag";
import DefaultProxyAdminTag from "@/components/common_components/DefaultProxyAdminTag";
interface ProjectKeysTableProps {
keys: KeyResponse[];

View File

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "../../../../tests/test-utils";
import { renderWithProviders, screen } from "../../../../../../tests/test-utils";
import { CreateProjectModal } from "./CreateProjectModal";
const mockMutate = vi.fn();

View File

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen } from "../../../../tests/test-utils";
import { renderWithProviders, screen } from "../../../../../../tests/test-utils";
import { EditProjectModal } from "./EditProjectModal";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";

View File

@ -1,7 +1,7 @@
import React from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "../../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../../../../tests/test-utils";
import { Form } from "antd";
import { ProjectBaseForm, ProjectFormValues } from "./ProjectBaseForm";

View File

@ -19,9 +19,9 @@ import type { FormInstance } from "antd";
import { PlusOutlined, MinusCircleOutlined } from "@ant-design/icons";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams";
import { Team } from "../../key_team_helpers/key_list";
import { fetchTeamModels } from "../../organisms/create_key_button";
import { getModelDisplayName } from "../../key_team_helpers/fetch_available_models_team_key";
import { Team } from "@/components/key_team_helpers/key_list";
import { fetchTeamModels } from "@/components/organisms/create_key_button";
import { getModelDisplayName } from "@/components/key_team_helpers/fetch_available_models_team_key";
import { getGuardrailsList } from "@/components/networking";
export interface ProjectFormValues {

View File

@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import userEvent from "@testing-library/user-event";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { renderWithProviders, screen, waitFor } from "../../../../../tests/test-utils";
import { ProjectsPage } from "./ProjectsPage";
import { ProjectResponse } from "@/app/(dashboard)/hooks/projects/useProjects";

View File

@ -0,0 +1,9 @@
"use client";
import { ProjectsPage } from "./components/ProjectsPage";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
export default function Projects() {
useAuthorized();
return <ProjectsPage />;
}

View File

@ -47,6 +47,14 @@ describe("migratedHref / legacyPageHref", () => {
expect(MIGRATED_PAGES["llm-playground"]).toBe("playground");
});
it("maps the projects and access-groups sidebar ids to their routes", async () => {
vi.doMock("@/components/networking", () => ({ serverRootPath: "/" }));
const { MIGRATED_PAGES } = await import("./migratedPages");
expect(MIGRATED_PAGES.projects).toBe("projects");
expect(MIGRATED_PAGES["access-groups"]).toBe("access-groups");
});
});
describe("dev server (NODE_ENV=development)", () => {

View File

@ -13,6 +13,8 @@ export const MIGRATED_PAGES: Record<string, string> = {
// Legacy alias: older bookmarks used the hyphenated ?page=api-reference form.
"api-reference": "api-reference",
"llm-playground": "playground",
projects: "projects",
"access-groups": "access-groups",
};
function uiBase(): string {

File diff suppressed because one or more lines are too long