Thanks to visit codestin.com
Credit goes to github.com

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions site/src/components/Sidebar/Sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { cn } from "cn";
import type { FC, ReactNode } from "react";
import { NavLink } from "react-router";
import { NavLink, type To } from "react-router";

interface SidebarProps {
children?: ReactNode;
Expand All @@ -15,7 +15,7 @@ export const Sidebar: FC<SidebarProps> = ({ className, children }) => {

interface SettingsSidebarNavItemProps {
children?: ReactNode;
href: string;
href: To;
end?: boolean;
}

Expand Down
151 changes: 113 additions & 38 deletions site/src/modules/management/AISettingsSidebarView.stories.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useLocation } from "react-router";
import { expect, userEvent, waitFor, within } from "storybook/test";
import { reactRouterParameters } from "storybook-addon-remix-react-router";
import {
type RouterRoute,
reactRouterParameters,
} from "storybook-addon-remix-react-router";
import { organizationsPermissions } from "#/api/queries/organizations";
import {
MockDefaultOrganization,
Expand All @@ -16,27 +20,68 @@ import {
import { AISettingsSidebar } from "./AISettingsSidebar";
import AISettingsSidebarView from "./AISettingsSidebarView";

const LocationProbe = () => {
const location = useLocation();
return (
<p role="status" aria-label="Current location" className="sr-only">
{location.pathname}
{location.search}
</p>
);
};

const aiSettingsRoutes: [RouterRoute, ...RouterRoute[]] = [
{ path: "/ai/settings/governance", useStoryElement: true },
{ path: "/ai/settings/gateway-keys", useStoryElement: true },
{ path: "/ai/settings/providers", useStoryElement: true },
{ path: "/ai/settings/coder-agents", useStoryElement: true },
{ path: "/ai/settings/models", useStoryElement: true },
{
path: "/ai/settings/organizations/:organization/models",
useStoryElement: true,
},
{ path: "/ai/settings/mcp-servers", useStoryElement: true },
{ path: "/ai/settings/mcp-servers/add", useStoryElement: true },
{ path: "/ai/settings/templates", useStoryElement: true },
{ path: "/ai/settings/instructions", useStoryElement: true },
{ path: "/ai/settings/lifecycle", useStoryElement: true },
];

const atLocation = (path: string, searchParams?: Record<string, string>) =>
reactRouterParameters({
location: { path, searchParams },
routing: aiSettingsRoutes,
});

const followLink = async (
canvas: ReturnType<typeof within>,
name: string,
expectedLocation: string,
) => {
await userEvent.click(canvas.getByRole("link", { name }));
await waitFor(() =>
expect(
canvas.getByRole("status", { name: "Current location" }).textContent,
).toBe(expectedLocation),
);
};

const meta: Meta<typeof AISettingsSidebarView> = {
title: "modules/management/AISettingsSidebarView",
component: AISettingsSidebarView,
decorators: [
(Story) => (
<>
<Story />
<LocationProbe />
</>
),
],
args: {
permissions: MockPermissions,
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/coder-agents" },
routing: [
{ path: "/ai/settings/governance", useStoryElement: true },
{ path: "/ai/settings/gateway-keys", useStoryElement: true },
{ path: "/ai/settings/providers", useStoryElement: true },
{ path: "/ai/settings/coder-agents", useStoryElement: true },
{ path: "/ai/settings/models", useStoryElement: true },
{ path: "/ai/settings/mcp-servers", useStoryElement: true },
{ path: "/ai/settings/templates", useStoryElement: true },
{ path: "/ai/settings/instructions", useStoryElement: true },
{ path: "/ai/settings/lifecycle", useStoryElement: true },
],
}),
reactRouter: atLocation("/ai/settings/coder-agents"),
},
};

Expand All @@ -55,10 +100,7 @@ export const CoderAgentsActive: Story = {

export const ModelsActive: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/models" },
routing: [{ path: "/ai/settings/models", useStoryElement: true }],
}),
reactRouter: atLocation("/ai/settings/models"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand All @@ -69,19 +111,36 @@ export const ModelsActive: Story = {
},
};

export const OrganizationParamPreserved: Story = {
parameters: {
reactRouter: atLocation("/ai/settings/models", { org: "my organization" }),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await followLink(
canvas,
"Coder Agents",
"/ai/settings/coder-agents?org=my+organization",
);
await followLink(
canvas,
"MCP servers",
"/ai/settings/mcp-servers?org=my+organization",
);
await followLink(
canvas,
"Models",
"/ai/settings/models?org=my+organization",
);
await followLink(canvas, "Providers", "/ai/settings/providers");
},
};

export const ModelsActiveOnOrganizationRoute: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: {
path: "/ai/settings/organizations/my-organization/models",
},
routing: [
{
path: "/ai/settings/organizations/:organization/models",
useStoryElement: true,
},
],
}),
reactRouter: atLocation(
"/ai/settings/organizations/my-organization/models",
),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand All @@ -94,19 +153,13 @@ export const ModelsActiveOnOrganizationRoute: Story = {

export const LifecycleActive: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/lifecycle" },
routing: [{ path: "/ai/settings/lifecycle", useStoryElement: true }],
}),
reactRouter: atLocation("/ai/settings/lifecycle"),
},
};

export const ProvidersActive: Story = {
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/ai/settings/providers" },
routing: [{ path: "/ai/settings/providers", useStoryElement: true }],
}),
reactRouter: atLocation("/ai/settings/providers"),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
Expand Down Expand Up @@ -299,6 +352,28 @@ export const MCPServersForCreateOnlyAdmin: Story = {
},
};

export const MCPServersForCreateOnlyAdminPreservesOrganization: Story = {
args: {
permissions: {
...MockNoPermissions,
createAnyMCPServerConfig: true,
},
},
parameters: {
reactRouter: atLocation("/ai/settings/mcp-servers", {
org: MockDefaultOrganization.name,
}),
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await followLink(
canvas,
"MCP servers",
`/ai/settings/mcp-servers/add?org=${MockDefaultOrganization.name}`,
);
},
};

export const MCPServersHiddenWithoutPermission: Story = {
args: {
permissions: {
Expand Down
60 changes: 49 additions & 11 deletions site/src/modules/management/AISettingsSidebarView.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { cn } from "cn";
import type { FC, ReactNode } from "react";
import { Link, NavLink, useMatch } from "react-router";
import {
Link,
NavLink,
type To,
useMatch,
useSearchParams,
} from "react-router";
import {
Sidebar as BaseSidebar,
SettingsSidebarNavItem as SidebarNavItem,
Expand All @@ -9,6 +15,7 @@ import {
canAccessAnyChatModelConfig,
type Permissions,
} from "#/modules/permissions";
import { modelOrganizationSearchParam } from "#/pages/AISettingsPage/ModelsPage/organizationModels";

interface AISettingsSidebarViewProps {
/** Site-wide permissions. */
Expand All @@ -17,7 +24,7 @@ interface AISettingsSidebarViewProps {
canShareOrganizationMCPServers?: boolean;
}

const SubNavItem: FC<{ href: string; children?: ReactNode }> = ({
const SubNavItem: FC<{ href: To; children?: ReactNode }> = ({
href,
children,
}) => (
Expand All @@ -36,7 +43,19 @@ const SubNavItem: FC<{ href: string; children?: ReactNode }> = ({
</NavLink>
);

const ModelsSidebarNavItem: FC = () => {
const organizationScopedPath = (
pathname: string,
organizationName: string | null,
): To => ({
pathname,
search: organizationName
? new URLSearchParams({
[modelOrganizationSearchParam]: organizationName,
}).toString()
: "",
});

const ModelsSidebarNavItem: FC<{ href: To }> = ({ href }) => {
const legacyMatch = useMatch("/ai/settings/models/*");
const organizationMatch = useMatch(
"/ai/settings/organizations/:organization/models/*",
Expand All @@ -45,7 +64,7 @@ const ModelsSidebarNavItem: FC = () => {

return (
<Link
to="/ai/settings/models"
to={href}
aria-current={isActive ? "page" : undefined}
className={cn(
"relative text-sm text-content-secondary no-underline font-medium py-2 px-3 hover:bg-surface-secondary rounded-md transition ease-in-out duration-150",
Expand All @@ -62,6 +81,25 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
canAccessOrganizationModels = false,
canShareOrganizationMCPServers = false,
}) => {
const [searchParams] = useSearchParams();
const organizationName = searchParams.get(modelOrganizationSearchParam);
const modelsPath = organizationScopedPath(
"/ai/settings/models",
organizationName,
);
const coderAgentsPath = organizationScopedPath(
"/ai/settings/coder-agents",
organizationName,
);
const mcpServersPath = organizationScopedPath(
"/ai/settings/mcp-servers",
organizationName,
);
const addMCPServerPath = organizationScopedPath(
"/ai/settings/mcp-servers/add",
organizationName,
);

return (
<BaseSidebar>
<div className="flex flex-col gap-1">
Expand All @@ -81,15 +119,15 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
</SidebarNavItem>
)}
{(canAccessAnyChatModelConfig(permissions) ||
canAccessOrganizationModels) && <ModelsSidebarNavItem />}
canAccessOrganizationModels) && (
<ModelsSidebarNavItem href={modelsPath} />
)}
{(permissions.editDeploymentConfig || canAccessOrganizationModels) && (
<SidebarNavItem href="/ai/settings/coder-agents">
Coder Agents
</SidebarNavItem>
<SidebarNavItem href={coderAgentsPath}>Coder Agents</SidebarNavItem>
)}
{permissions.editDeploymentConfig && (
<div className="flex flex-col gap-1 ml-3 border-0 border-solid border-l border-l-border">
<SubNavItem href="/ai/settings/mcp-servers">MCP servers</SubNavItem>
<SubNavItem href={mcpServersPath}>MCP servers</SubNavItem>
{permissions.updateAnyTemplate && (
<SubNavItem href="/ai/settings/templates">Templates</SubNavItem>
)}
Expand All @@ -112,8 +150,8 @@ const AISettingsSidebarView: FC<AISettingsSidebarViewProps> = ({
permissions.updateAnyMCPServerConfig ||
permissions.deleteAnyMCPServerConfig ||
canShareOrganizationMCPServers
? "/ai/settings/mcp-servers"
: "/ai/settings/mcp-servers/add"
? mcpServersPath
: addMCPServerPath
}
>
MCP servers
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "#/testHelpers/entities";
import {
organizationAddModelPath,
organizationModelsPath,
selectModelOrganization,
useAccessibleModelOrganizations,
} from "./organizationModels";
Expand Down Expand Up @@ -67,6 +68,12 @@ describe("selectModelOrganization", () => {
});
});

it("builds an organization-scoped models path", () => {
expect(organizationModelsPath(MockOrganization2)).toBe(
`/ai/settings/models?org=${MockOrganization2.name}`,
);
});

it("preserves auxiliary parameters in organization model paths", () => {
const params = new URLSearchParams({
provider: "openai",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,11 @@ const organizationModelSettingsPath = (
return `/ai/settings/models${suffix}?${next.toString()}`;
};

export const organizationModelsPath = (
organization: Organization,
searchParams?: URLSearchParams,
): string => organizationModelSettingsPath(organization, "", searchParams);

export const organizationAddModelPath = (
organization: Organization,
searchParams?: URLSearchParams,
Expand All @@ -168,5 +173,5 @@ export const organizationModelPath = (
export const useOrganizationModelsPath = (): string => {
const { organization } = useOrganizationModels();
const [searchParams] = useSearchParams();
return organizationModelSettingsPath(organization, "", searchParams);
return organizationModelsPath(organization, searchParams);
};
Loading
Loading