mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
feat: reposition org pickers in AI settings models and MCP pages (#28564)
Repositions the organization picker on the AI settings models and MCP servers pages so it lives with the content it scopes instead of floating above the page header. **Models** (`/ai/settings/models`) - The picker moved out of `OrganizationModelsLayout` into a shared `ModelOrganizationSelect` component that preserves the current path and auxiliary query params when switching orgs. - List page: rendered in the filter row to the right of search. - Add/edit model form: rendered as row 3 of the form grid at 50% width, labeled "Organization". On the edit page it is informational only (a static value, not a picker), since switching org there would 404 the model. Also rendered in the no-provider fallback and "Provider not found" states so the switcher never disappears on those pages. **MCP servers** (`/ai/settings/mcp-servers`) - List page: new client-side search input (matches display name, slug, and URL) with the org picker beside it, label hidden. - Add/edit server form: the picker moved into the form as the third cell of the first row (slug, display name, organization), making it a 3-up. - `OrganizationPicker` now renders a static read-only value instead of a disabled button when the org cannot be changed (no handler or single org). This fixes the muted `content-disabled` text on the update page. The read-only rendering is shared with the models edit page via a new `OrganizationValue` component beside `OrganizationAutocomplete`. **Status columns** (both list tables) - The visible "Status" header and the Enabled/Disabled badges are removed; both list tables drop the status column entirely. Models whose provider is deleted or disabled show a warning "Unavailable" badge (with an explanatory tooltip) beside the name. - Disabled models and MCP servers show a "Disabled" badge beside the name, matching the existing "Default" badge placement, and the row dims like disabled providers: avatar/icon at half opacity, text in `content-disabled`. <details> <summary>Decision log</summary> - `ModelOrganizationSelect` reuses `OrganizationAutocomplete` and reads accessible organizations from the models context (`accessibleOrganizations` added to `OrganizationModelsContext`), rather than duplicating the layout's navigation logic per page. - Navigation semantics are unchanged: switching orgs rewrites the `org` search param and preserves the path and remaining params, exactly as the old layout-level picker did. - The MCP `OrganizationPicker` read-only state uses an `<output>` element (labelable, keeps the `Label` association) rather than a disabled button, so non-interactive values do not render with disabled styling or sit in the tab order. - The add MCP server page keeps a standalone picker above the "cannot add servers" alert since the form (and its picker slot) is not rendered in that state. - Story review: one interaction story per new behavior. A `ModelsPageView` story duplicating the shared picker's select-and-navigate flow was deliberately dropped; list-page placement is covered by the `OrganizationModelsLayout` stories that mount the real `ModelsPage`. The `ModelForm` fallback-branch picker has no dedicated story since its sibling branch and the shared flow are covered. </details> Verification: `pnpm lint:types`, `pnpm check`, and all affected Storybook tests pass (ModelsPage and MCPServersPage: 140), plus the `organizationModels` and `mcpServerFormLogic` unit tests. --- 🤖 This PR was generated by Coder Agents on behalf of @tracyjohnsonux. --------- Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
This commit is contained in:
@@ -210,11 +210,11 @@ To change the default model:
|
||||
The Models list reflects whether each model can actually be used:
|
||||
|
||||
- When a model's connected provider has been deleted, the **Provider** column
|
||||
shows **Unset** with an info tooltip that reads "The provider connected to
|
||||
this model has been deleted."
|
||||
- When a model's provider is missing or disabled, the **Status** column
|
||||
shows **Disabled**, regardless of the model's own enabled setting. Such a
|
||||
model cannot serve chat requests.
|
||||
shows **Unset**.
|
||||
- When a model's provider is missing or disabled, an **Unavailable** badge
|
||||
appears beside the model name. The badge's tooltip explains whether the
|
||||
provider was deleted or disabled. Such a model cannot serve chat requests.
|
||||
- When a model is disabled, a **Disabled** badge appears beside the model name.
|
||||
|
||||
To reconnect a model to a working provider, open the model from the list,
|
||||
pick a new provider from the **Provider** dropdown, and click **Save**. The
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "#/components/Command/Command";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -92,7 +93,7 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
aria-required={required}
|
||||
data-testid="organization-autocomplete"
|
||||
className={cn(
|
||||
"w-full justify-start gap-2 font-normal",
|
||||
"group w-full justify-start gap-2 font-normal",
|
||||
triggerClassName,
|
||||
)}
|
||||
>
|
||||
@@ -154,3 +155,129 @@ export const OrganizationAutocomplete: FC<OrganizationAutocompleteProps> = ({
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
|
||||
type OrganizationValueProps = {
|
||||
organization: Organization;
|
||||
labelOrganizations?: readonly Organization[];
|
||||
id?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const OrganizationValue: FC<OrganizationValueProps> = ({
|
||||
organization,
|
||||
labelOrganizations,
|
||||
id,
|
||||
className,
|
||||
}) => {
|
||||
const label = getOrganizationLabel(
|
||||
organization,
|
||||
labelOrganizations ?? [organization],
|
||||
);
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
role="group"
|
||||
aria-label={`Organization ${label}`}
|
||||
className={cn(
|
||||
"flex h-10 items-center gap-2 rounded-md border border-solid border-border px-3 py-2 text-sm text-content-primary",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={organization.icon}
|
||||
fallback={organization.display_name}
|
||||
/>
|
||||
<span className="truncate">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type OrganizationFieldProps = {
|
||||
id: string;
|
||||
organization: Organization;
|
||||
organizations: readonly Organization[];
|
||||
labelOrganizations?: readonly Organization[];
|
||||
onChange?: (organization: Organization) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
showLabel?: boolean;
|
||||
showSingleOrganization?: boolean;
|
||||
readOnly?: boolean;
|
||||
triggerClassName?: string;
|
||||
optionsTabbable?: boolean;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export const OrganizationField: FC<OrganizationFieldProps> = ({
|
||||
id,
|
||||
organization,
|
||||
organizations,
|
||||
labelOrganizations,
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
label = "Organization",
|
||||
showLabel = true,
|
||||
showSingleOrganization = false,
|
||||
readOnly = false,
|
||||
triggerClassName,
|
||||
optionsTabbable,
|
||||
required = true,
|
||||
}) => {
|
||||
const hasSingleSelectedOrganization =
|
||||
organizations.length <= 1 &&
|
||||
organizations.some((option) => option.id === organization.id);
|
||||
if (hasSingleSelectedOrganization && !showSingleOrganization && !readOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const resolvedLabelOrganizations =
|
||||
labelOrganizations ??
|
||||
(organizations.some((option) => option.id === organization.id)
|
||||
? organizations
|
||||
: [...organizations, organization]);
|
||||
const organizationLabel = getOrganizationLabel(
|
||||
organization,
|
||||
resolvedLabelOrganizations,
|
||||
);
|
||||
const isReadOnly = readOnly || !onChange || hasSingleSelectedOrganization;
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-72 flex-col gap-1.5", className)}>
|
||||
{showLabel && (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className="flex items-center gap-1 leading-6 text-content-primary"
|
||||
>
|
||||
{label}
|
||||
</Label>
|
||||
)}
|
||||
{isReadOnly ? (
|
||||
<OrganizationValue
|
||||
id={id}
|
||||
organization={organization}
|
||||
labelOrganizations={resolvedLabelOrganizations}
|
||||
/>
|
||||
) : (
|
||||
<OrganizationAutocomplete
|
||||
id={id}
|
||||
ariaLabel={`${label} ${organizationLabel}`}
|
||||
value={organization}
|
||||
onChange={(org) => {
|
||||
if (org) {
|
||||
onChange?.(org);
|
||||
}
|
||||
}}
|
||||
options={organizations}
|
||||
labelOrganizations={resolvedLabelOrganizations}
|
||||
required={required}
|
||||
disabled={disabled}
|
||||
triggerClassName={triggerClassName}
|
||||
optionsTabbable={optionsTabbable}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
+3
-3
@@ -38,9 +38,9 @@ export const Default: Story = {
|
||||
const addButton = canvas.getByRole("button", { name: "Add server" });
|
||||
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
canvas.getByLabelText(
|
||||
`Organization ${MockDefaultOrganization.display_name}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(addButton).toBeDisabled();
|
||||
await userEvent.type(canvas.getByLabelText(/display name/i), "GitHub");
|
||||
|
||||
+29
-15
@@ -34,15 +34,6 @@ const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle("Add server", "AI Settings")}</title>
|
||||
<OrganizationPicker
|
||||
id="mcp-add-organization"
|
||||
className="mb-6"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
disabled={isSaving}
|
||||
showSingleOrganization
|
||||
/>
|
||||
{canCreate ? (
|
||||
<MCPServerForm
|
||||
listPath={
|
||||
@@ -50,16 +41,39 @@ const AddMCPServerPageView: FC<AddMCPServerPageViewProps> = ({
|
||||
}
|
||||
isSaving={isSaving}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
organizationPicker={
|
||||
<OrganizationPicker
|
||||
id="mcp-add-organization"
|
||||
className="w-full"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
disabled={isSaving}
|
||||
showSingleOrganization
|
||||
/>
|
||||
}
|
||||
onCreateServer={onCreateServer}
|
||||
onCancel={canViewServerList ? onCancel : undefined}
|
||||
/>
|
||||
) : (
|
||||
<Alert severity="error" prominent>
|
||||
<AlertTitle>You cannot add servers to this organization</AlertTitle>
|
||||
<AlertDescription>
|
||||
Choose an organization where you have permission to add MCP servers.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<>
|
||||
<OrganizationPicker
|
||||
id="mcp-add-organization"
|
||||
className="mb-6"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
disabled={isSaving}
|
||||
showSingleOrganization
|
||||
/>
|
||||
<Alert severity="error" prominent>
|
||||
<AlertTitle>You cannot add servers to this organization</AlertTitle>
|
||||
<AlertDescription>
|
||||
Choose an organization where you have permission to add MCP
|
||||
servers.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
import AddMCPServerPage from "./AddMCPServerPage/AddMCPServerPage";
|
||||
import MCPServersPage from "./MCPServersPage";
|
||||
import { orgSearchParam } from "./organizationParam";
|
||||
import { MockCoderMCPServer } from "./testFixtures";
|
||||
import { MockCoderMCPServer, MockGitHubMCPServer } from "./testFixtures";
|
||||
import UpdateMCPServerPage from "./UpdateMCPServerPage/UpdateMCPServerPage";
|
||||
|
||||
const MockOrganization2MCPServer: TypesGen.MCPServerConfig = {
|
||||
@@ -580,15 +580,55 @@ export const AddDeepLinkShowsSingleCreatableOrganization: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const organization = await canvas.findByRole("button", {
|
||||
name: `Organization ${MockOrganization2.display_name}`,
|
||||
});
|
||||
const organization = await canvas.findByLabelText(
|
||||
`Organization ${MockOrganization2.display_name}`,
|
||||
);
|
||||
await expect(organization).toBeVisible();
|
||||
await expect(organization).toBeDisabled();
|
||||
expect(
|
||||
canvas.queryByRole("button", {
|
||||
name: `Organization ${MockOrganization2.display_name}`,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(canvas.getByLabelText(/display name/i)).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const ListSearchFiltersServers: Story = {
|
||||
parameters: {
|
||||
organizations: [MockDefaultOrganization, MockOrganization2],
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/ai/settings/mcp-servers" },
|
||||
routing: { path: "/ai/settings/mcp-servers" },
|
||||
}),
|
||||
},
|
||||
beforeEach: () => {
|
||||
spyOn(API.experimental, "getMCPServerConfigs").mockResolvedValue([
|
||||
MockCoderMCPServer,
|
||||
MockGitHubMCPServer,
|
||||
]);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByText("Coder")).toBeVisible();
|
||||
await expect(canvas.getByText("GitHub")).toBeVisible();
|
||||
|
||||
const search = canvas.getByRole("searchbox", { name: "Search servers" });
|
||||
await userEvent.type(search, "github");
|
||||
await expect(canvas.getByText("GitHub")).toBeVisible();
|
||||
expect(canvas.queryByText("Coder")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(search);
|
||||
await userEvent.type(search, "no-such-server");
|
||||
await expect(
|
||||
canvas.getByText("No servers match your search"),
|
||||
).toBeVisible();
|
||||
|
||||
await userEvent.clear(search);
|
||||
await expect(canvas.getByText("Coder")).toBeVisible();
|
||||
await expect(canvas.getByText("GitHub")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const ListSwitchesOrganization: Story = {
|
||||
parameters: {
|
||||
organizations: [MockDefaultOrganization, MockOrganization2],
|
||||
@@ -1282,6 +1322,7 @@ export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = {
|
||||
await expect(await canvas.findByLabelText(/display name/i)).toHaveValue(
|
||||
"Coder",
|
||||
);
|
||||
await userEvent.type(canvas.getByLabelText(/display name/i), " v2");
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Update server" }),
|
||||
).toBeEnabled();
|
||||
@@ -1301,7 +1342,7 @@ export const UpdateOnlyOrgAdminCanUpdateMCPServer: Story = {
|
||||
body.queryByRole("option", { name: "User OIDC identity" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "Server actions" }),
|
||||
canvas.queryByRole("button", { name: "Delete" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: /delete server/i }),
|
||||
@@ -1375,7 +1416,6 @@ export const UserOIDCOrgAdminCannotUpdate: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await expect(await canvas.findByLabelText(/display name/i)).toHaveValue(
|
||||
"Coder",
|
||||
);
|
||||
@@ -1399,12 +1439,7 @@ export const UserOIDCOrgAdminCannotUpdate: Story = {
|
||||
await expect(
|
||||
canvas.getByLabelText(/authentication method/i),
|
||||
).toHaveTextContent("User OIDC identity");
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Server actions" }),
|
||||
);
|
||||
await expect(
|
||||
await body.findByRole("menuitem", { name: "Remove" }),
|
||||
).toBeEnabled();
|
||||
await expect(canvas.getByRole("button", { name: "Delete" })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1469,12 +1504,7 @@ export const DeleteOnlyOrgAdminCanDeleteWithoutUpdating: Story = {
|
||||
).toBeDisabled();
|
||||
await expect(canvas.getByLabelText(/tool allow list/i)).toBeDisabled();
|
||||
await expect(canvas.getByLabelText(/tool deny list/i)).toBeDisabled();
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "Server actions" }),
|
||||
);
|
||||
await userEvent.click(
|
||||
await body.findByRole("menuitem", { name: "Remove" }),
|
||||
);
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Delete" }));
|
||||
await userEvent.click(
|
||||
await body.findByRole("button", { name: "Delete MCP server" }),
|
||||
);
|
||||
|
||||
@@ -104,6 +104,8 @@ const MCPServersPage: FC = () => {
|
||||
)}
|
||||
{organization && (
|
||||
<MCPServersPageView
|
||||
// Reset view-local state (search) when the organization changes.
|
||||
key={organization.id}
|
||||
isLoading={serversQuery.isLoading}
|
||||
error={serversQuery.error}
|
||||
servers={servers}
|
||||
|
||||
@@ -58,8 +58,9 @@ export const Default: Story = {
|
||||
await expect(canvas.getByText("GitHub")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Image")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("API key")).toBeInTheDocument();
|
||||
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
|
||||
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
|
||||
expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
|
||||
const disabledRow = canvas.getByRole("button", { name: /Image/i });
|
||||
await expect(within(disabledRow).getByText("Disabled")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { PlusIcon, SearchIcon } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
InputGroup,
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { getOrganizationLabel } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import {
|
||||
SettingsHeader,
|
||||
@@ -47,6 +52,17 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
onSelectOrganization,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
const filteredServers =
|
||||
normalizedQuery.length === 0
|
||||
? servers
|
||||
: servers.filter((server) =>
|
||||
[server.display_name, server.slug, server.url]
|
||||
.join(" ")
|
||||
.toLowerCase()
|
||||
.includes(normalizedQuery),
|
||||
);
|
||||
// Disambiguate against every organization sharing the page context:
|
||||
// other creation targets and the currently selected organization.
|
||||
const addButtonLabel =
|
||||
@@ -85,13 +101,30 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
Agents.
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
<OrganizationPicker
|
||||
id="mcp-servers-organization"
|
||||
className="mb-4"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
/>
|
||||
<div className="mb-4 flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<InputGroup>
|
||||
<InputGroupAddon>
|
||||
<SearchIcon />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
placeholder="Search servers..."
|
||||
aria-label="Search servers"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<OrganizationPicker
|
||||
id="mcp-servers-organization"
|
||||
className="w-full sm:w-60"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onSelectOrganization}
|
||||
showLabel={false}
|
||||
/>
|
||||
</div>
|
||||
{Boolean(error) && (
|
||||
<div className="mb-4">
|
||||
<ErrorAlert error={error} />
|
||||
@@ -103,7 +136,6 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
<TableHead className="w-1/2">Name</TableHead>
|
||||
<TableHead className="w-1/5">Auth Method</TableHead>
|
||||
<TableHead className="w-1/5">Availability</TableHead>
|
||||
<TableHead className="w-32">Status</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<span className="sr-only">Open server</span>
|
||||
</TableHead>
|
||||
@@ -130,8 +162,13 @@ const MCPServersPageView: FC<MCPServersPageViewProps> = ({
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
) : servers.length > 0 && filteredServers.length === 0 ? (
|
||||
<TableEmpty
|
||||
message="No servers match your search"
|
||||
description="Try a different search term."
|
||||
/>
|
||||
) : (
|
||||
servers.map((server) => (
|
||||
filteredServers.map((server) => (
|
||||
<MCPServerRow
|
||||
key={server.id}
|
||||
server={server}
|
||||
|
||||
+13
-21
@@ -45,9 +45,9 @@ export const Default: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
canvas.getByLabelText(
|
||||
`Organization ${MockDefaultOrganization.display_name}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
await expect(canvas.getByLabelText(/display name/i)).toHaveValue("Coder");
|
||||
await userEvent.click(
|
||||
@@ -58,6 +58,10 @@ export const Default: Story = {
|
||||
);
|
||||
|
||||
const updateButton = canvas.getByRole("button", { name: "Update server" });
|
||||
await expect(updateButton).toBeDisabled();
|
||||
const displayName = canvas.getByLabelText(/display name/i);
|
||||
await userEvent.clear(displayName);
|
||||
await userEvent.type(displayName, "Coder v2");
|
||||
await expect(updateButton).toBeEnabled();
|
||||
await userEvent.click(updateButton);
|
||||
|
||||
@@ -65,7 +69,7 @@ export const Default: Story = {
|
||||
expect(onUpdateServer).toHaveBeenCalledWith(
|
||||
"mcp-coder",
|
||||
expect.objectContaining({
|
||||
display_name: "Coder",
|
||||
display_name: "Coder v2",
|
||||
slug: "coder",
|
||||
}),
|
||||
);
|
||||
@@ -111,9 +115,6 @@ export const DeleteOnly: Story = {
|
||||
await expect(
|
||||
canvas.getByRole("button", { name: "Update server" }),
|
||||
).toBeDisabled();
|
||||
const serverActions = canvas.getByRole("button", {
|
||||
name: "Server actions",
|
||||
});
|
||||
const enabledSwitch = canvas.getByRole("switch", {
|
||||
name: "Server enabled",
|
||||
});
|
||||
@@ -121,16 +122,11 @@ export const DeleteOnly: Story = {
|
||||
await expect(enabledSwitch).toHaveAccessibleDescription(
|
||||
"You do not have permission to update this server.",
|
||||
);
|
||||
serverActions.focus();
|
||||
await userEvent.tab();
|
||||
await expect(enabledSwitch).toHaveFocus();
|
||||
enabledSwitch.focus();
|
||||
await expect(await body.findByRole("tooltip")).toHaveTextContent(
|
||||
"You do not have permission to update this server.",
|
||||
);
|
||||
await userEvent.click(serverActions);
|
||||
await expect(
|
||||
await body.findByRole("menuitem", { name: "Remove" }),
|
||||
).toBeEnabled();
|
||||
await expect(canvas.getByRole("button", { name: "Delete" })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -145,15 +141,11 @@ export const ShareOnlyAccess: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
await expect(canvas.getByLabelText(/display name/i)).toBeDisabled();
|
||||
const serverActions = canvas.getByRole("button", {
|
||||
name: "Server actions",
|
||||
});
|
||||
await userEvent.click(serverActions);
|
||||
expect(
|
||||
body.queryByRole("menuitem", { name: "Remove" }),
|
||||
canvas.queryByRole("button", { name: "Delete" }),
|
||||
).not.toBeInTheDocument();
|
||||
await userEvent.click(
|
||||
await body.findByRole("menuitem", { name: "Manage permissions" }),
|
||||
canvas.getByRole("button", { name: "Manage permissions" }),
|
||||
);
|
||||
await expect(
|
||||
await body.findByRole("dialog", { name: "Server permissions" }),
|
||||
@@ -172,7 +164,7 @@ export const NoShareReadOnlyAccess: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByLabelText(/display name/i)).toBeDisabled();
|
||||
expect(
|
||||
canvas.queryByRole("button", { name: "Server actions" }),
|
||||
canvas.queryByRole("button", { name: "Manage permissions" }),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
+9
-7
@@ -39,13 +39,6 @@ const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
|
||||
return (
|
||||
<>
|
||||
<title>{pageTitle(server.display_name, "AI Settings")}</title>
|
||||
<OrganizationPicker
|
||||
id="mcp-update-organization"
|
||||
className="mb-6"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
showSingleOrganization
|
||||
/>
|
||||
<MCPServerForm
|
||||
key={server.id}
|
||||
server={server}
|
||||
@@ -54,6 +47,15 @@ const UpdateMCPServerPageView: FC<UpdateMCPServerPageViewProps> = ({
|
||||
isDeleting={isDeleting}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
canShareServer={canShareServer}
|
||||
organizationPicker={
|
||||
<OrganizationPicker
|
||||
id="mcp-update-organization"
|
||||
className="w-full"
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
showSingleOrganization
|
||||
/>
|
||||
}
|
||||
onUpdateServer={onUpdateServer}
|
||||
onDeleteServer={onDeleteServer}
|
||||
onToggleEnabled={onToggleEnabled}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useFormik } from "formik";
|
||||
import { type FC, useState } from "react";
|
||||
import { type FC, type ReactNode, useState } from "react";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
|
||||
import { MCPServerFormDialogs } from "./MCPServerFormDialogs";
|
||||
@@ -22,6 +22,7 @@ type MCPServerFormCreateProps = {
|
||||
isSaving: boolean;
|
||||
isDeleting?: false;
|
||||
canSelectUserOIDC: boolean;
|
||||
organizationPicker?: ReactNode;
|
||||
canShareServer?: false;
|
||||
onCreateServer: (
|
||||
req: TypesGen.CreateMCPServerConfigRequest,
|
||||
@@ -38,6 +39,7 @@ type MCPServerFormEditProps = {
|
||||
isSaving: boolean;
|
||||
isDeleting: boolean;
|
||||
canSelectUserOIDC: boolean;
|
||||
organizationPicker?: ReactNode;
|
||||
canShareServer?: boolean;
|
||||
onCreateServer?: undefined;
|
||||
onUpdateServer?: (
|
||||
@@ -57,6 +59,7 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
|
||||
isSaving,
|
||||
isDeleting = false,
|
||||
canSelectUserOIDC,
|
||||
organizationPicker,
|
||||
canShareServer = false,
|
||||
onCreateServer,
|
||||
onUpdateServer,
|
||||
@@ -95,7 +98,10 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
|
||||
const isDisabled = isSaving || isDeleting;
|
||||
const areFieldsDisabled =
|
||||
isDisabled || (isEditing && onUpdateServer === undefined);
|
||||
const canSubmit = canSubmitMCPServerForm(form.values, areFieldsDisabled);
|
||||
// Editing requires a change before submitting, matching the provider form.
|
||||
const canSubmit =
|
||||
canSubmitMCPServerForm(form.values, areFieldsDisabled) &&
|
||||
(!isEditing || form.dirty);
|
||||
const unsavedChanges = useUnsavedChangesPrompt(
|
||||
form.dirty && !form.isSubmitting,
|
||||
);
|
||||
@@ -126,6 +132,7 @@ export const MCPServerForm: FC<MCPServerFormProps> = ({
|
||||
canSubmit={canSubmit}
|
||||
isEditing={isEditing}
|
||||
canSelectUserOIDC={canSelectUserOIDC}
|
||||
organizationPicker={organizationPicker}
|
||||
onCancel={onCancel}
|
||||
showDetails={showDetails}
|
||||
setShowDetails={setShowDetails}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FormikContextType } from "formik";
|
||||
import { type FC, useId } from "react";
|
||||
import { type FC, type ReactNode, useId } from "react";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { Input } from "#/components/Input/Input";
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
SelectValue,
|
||||
} from "#/components/Select/Select";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { MCPServerAuthSection } from "./MCPServerAuthSection";
|
||||
import { MCPServerBehaviorSection } from "./MCPServerBehaviorSection";
|
||||
import { CollapsibleSection, Field } from "./MCPServerFormFieldPrimitives";
|
||||
@@ -31,6 +32,7 @@ interface MCPServerFormFieldsProps {
|
||||
canSubmit: boolean;
|
||||
isEditing: boolean;
|
||||
canSelectUserOIDC: boolean;
|
||||
organizationPicker?: ReactNode;
|
||||
onCancel?: () => void;
|
||||
showDetails: boolean;
|
||||
setShowDetails: (open: boolean) => void;
|
||||
@@ -47,6 +49,7 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
|
||||
canSubmit,
|
||||
isEditing,
|
||||
canSelectUserOIDC,
|
||||
organizationPicker,
|
||||
onCancel,
|
||||
showDetails,
|
||||
setShowDetails,
|
||||
@@ -65,7 +68,12 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
|
||||
autoComplete="off"
|
||||
className="flex flex-col gap-6"
|
||||
>
|
||||
<div className="grid items-start gap-4 sm:grid-cols-2">
|
||||
<div
|
||||
className={cn(
|
||||
"grid items-start gap-4",
|
||||
organizationPicker ? "sm:grid-cols-3" : "sm:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
<Field label="Slug" htmlFor={`${formId}-slug`} required>
|
||||
<Input
|
||||
id={`${formId}-slug`}
|
||||
@@ -97,7 +105,13 @@ export const MCPServerFormFields: FC<MCPServerFormFieldsProps> = ({
|
||||
disabled={isDisabled}
|
||||
/>
|
||||
</Field>
|
||||
<div className="grid items-start gap-4 sm:col-span-2 sm:grid-cols-[1fr_224px]">
|
||||
{organizationPicker}
|
||||
<div
|
||||
className={cn(
|
||||
"grid items-start gap-4 sm:grid-cols-[1fr_224px]",
|
||||
organizationPicker ? "sm:col-span-3" : "sm:col-span-2",
|
||||
)}
|
||||
>
|
||||
<Field label="Server URL" htmlFor={`${formId}-url`} required>
|
||||
<InputGroup>
|
||||
<InputGroupInput
|
||||
|
||||
@@ -1,21 +1,9 @@
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
EllipsisVerticalIcon,
|
||||
Share2Icon,
|
||||
TrashIcon,
|
||||
} from "lucide-react";
|
||||
import { ArrowLeftIcon, Share2Icon } from "lucide-react";
|
||||
import { type FC, useId } from "react";
|
||||
import { Link } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Badge } from "#/components/Badge/Badge";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "#/components/DropdownMenu/DropdownMenu";
|
||||
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Switch } from "#/components/Switch/Switch";
|
||||
import {
|
||||
@@ -61,66 +49,61 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
const disabledReasonId = useId();
|
||||
const lacksUpdatePermission = !onToggleEnabled;
|
||||
const lacksUpdatePermission = isEditing && server && !onToggleEnabled;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
{listPath && <MCPServerFormBackLink to={listPath} />}
|
||||
{isEditing && server && (onRequestDelete || onShareServer) && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{isEditing && server && (onShareServer || onRequestDelete) && (
|
||||
<div className="flex items-center gap-2">
|
||||
{onShareServer && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={isDisabled}
|
||||
aria-label="Server actions"
|
||||
onClick={onShareServer}
|
||||
>
|
||||
<EllipsisVerticalIcon />
|
||||
<Share2Icon />
|
||||
<span>Manage permissions</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{onShareServer && (
|
||||
<DropdownMenuItem onClick={onShareServer}>
|
||||
<Share2Icon />
|
||||
Manage permissions
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onShareServer && onRequestDelete && <DropdownMenuSeparator />}
|
||||
{onRequestDelete && (
|
||||
<DropdownMenuItem
|
||||
className="text-content-destructive focus:text-content-destructive"
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<TrashIcon />
|
||||
Remove
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
{onRequestDelete && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
disabled={isDisabled}
|
||||
onClick={onRequestDelete}
|
||||
>
|
||||
<span>Delete</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
{isEditing && (
|
||||
<MCPServerIcon iconUrl={iconUrl} name={title} className="size-12" />
|
||||
)}
|
||||
<SettingsHeaderTitle>
|
||||
<span
|
||||
className={cn(
|
||||
"block min-w-0 truncate",
|
||||
server?.enabled === false && "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</SettingsHeaderTitle>
|
||||
{isEditing && server && !server.enabled && (
|
||||
<Badge variant="default">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && server && (
|
||||
<div className="flex items-center gap-4 pt-6 min-w-0">
|
||||
{isEditing && (
|
||||
<MCPServerIcon iconUrl={iconUrl} name={title} className="size-12" />
|
||||
)}
|
||||
<SettingsHeaderTitle>
|
||||
<span
|
||||
className={cn(
|
||||
"block min-w-0 truncate",
|
||||
server?.enabled === false && "text-content-secondary",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</SettingsHeaderTitle>
|
||||
{isEditing && server && !server.enabled && (
|
||||
<Badge variant="default">Disabled</Badge>
|
||||
)}
|
||||
</div>
|
||||
{isEditing && server && (
|
||||
<div className="flex items-center justify-between w-full pt-6">
|
||||
<p className="text-sm text-content-secondary m-0">
|
||||
Disabled servers are hidden from agents.
|
||||
</p>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -128,16 +111,14 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
|
||||
<Switch
|
||||
checked={server.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
if (onToggleEnabled) {
|
||||
onToggleEnabled(checked);
|
||||
}
|
||||
onToggleEnabled?.(checked);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
aria-disabled={lacksUpdatePermission}
|
||||
aria-label="Server enabled"
|
||||
aria-describedby={
|
||||
lacksUpdatePermission ? disabledReasonId : undefined
|
||||
}
|
||||
aria-label="Server enabled"
|
||||
className="aria-disabled:cursor-not-allowed aria-disabled:data-[state=checked]:bg-surface-tertiary aria-disabled:data-[state=unchecked]:bg-surface-tertiary"
|
||||
/>
|
||||
</span>
|
||||
@@ -157,8 +138,8 @@ export const MCPServerFormHeader: FC<MCPServerFormHeaderProps> = ({
|
||||
)}
|
||||
<span className="text-sm">Enable</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,7 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
|
||||
<MCPServerIcon
|
||||
iconUrl={server.icon_url}
|
||||
name={server.display_name}
|
||||
className="size-10"
|
||||
className={cn("size-10", !enabled && "opacity-50")}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
@@ -36,17 +36,23 @@ export const MCPServerRow: FC<MCPServerRowProps> = ({ server, onClick }) => {
|
||||
>
|
||||
{server.display_name}
|
||||
</span>
|
||||
{!enabled && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="w-1/5 text-sm">
|
||||
<TableCell
|
||||
className={cn("w-1/5 text-sm", !enabled && "text-content-disabled")}
|
||||
>
|
||||
{AUTH_TYPE_LABELS[server.auth_type] ?? server.auth_type}
|
||||
</TableCell>
|
||||
<TableCell className="w-1/5 text-sm">
|
||||
<TableCell
|
||||
className={cn("w-1/5 text-sm", !enabled && "text-content-disabled")}
|
||||
>
|
||||
{AVAILABILITY_LABELS[server.availability] ?? server.availability}
|
||||
</TableCell>
|
||||
<TableCell className="w-32">
|
||||
<Badge variant="default">{enabled ? "Enabled" : "Disabled"}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="w-12">
|
||||
{onClick && (
|
||||
<ChevronRightIcon className="size-5 text-content-primary" />
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
import type { FC } from "react";
|
||||
import type { Organization } from "#/api/typesGenerated";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import {
|
||||
getOrganizationLabel,
|
||||
OrganizationAutocomplete,
|
||||
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { cn } from "#/utils/cn";
|
||||
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
|
||||
interface OrganizationPickerProps {
|
||||
id: string;
|
||||
@@ -14,6 +9,7 @@ interface OrganizationPickerProps {
|
||||
onChange?: (organization: Organization) => void;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
showLabel?: boolean;
|
||||
showSingleOrganization?: boolean;
|
||||
}
|
||||
|
||||
@@ -24,41 +20,17 @@ export const OrganizationPicker: FC<OrganizationPickerProps> = ({
|
||||
onChange,
|
||||
className,
|
||||
disabled,
|
||||
showLabel = true,
|
||||
showSingleOrganization = false,
|
||||
}) => {
|
||||
const hasSingleSelectedOrganization =
|
||||
organizations.length <= 1 &&
|
||||
organizations.some((option) => option.id === organization.id);
|
||||
if (hasSingleSelectedOrganization && !showSingleOrganization) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// The selected organization can fall outside the selectable options,
|
||||
// such as a deep link to an organization where servers are listable
|
||||
// but not creatable, so include it when disambiguating labels.
|
||||
const labelOrganizations = organizations.some(
|
||||
(option) => option.id === organization.id,
|
||||
)
|
||||
? organizations
|
||||
: [...organizations, organization];
|
||||
|
||||
return (
|
||||
<div className={cn("flex w-72 flex-col gap-2", className)}>
|
||||
<Label htmlFor={id}>Organization</Label>
|
||||
<OrganizationAutocomplete
|
||||
id={id}
|
||||
ariaLabel={`Organization ${getOrganizationLabel(organization, labelOrganizations)}`}
|
||||
value={organization}
|
||||
onChange={(org) => {
|
||||
if (org) {
|
||||
onChange?.(org);
|
||||
}
|
||||
}}
|
||||
options={organizations}
|
||||
labelOrganizations={labelOrganizations}
|
||||
required
|
||||
disabled={disabled || !onChange || hasSingleSelectedOrganization}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<OrganizationField
|
||||
id={id}
|
||||
organizations={organizations}
|
||||
organization={organization}
|
||||
onChange={onChange}
|
||||
className={className}
|
||||
disabled={disabled}
|
||||
showLabel={showLabel}
|
||||
showSingleOrganization={showSingleOrganization}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { expect, fn, screen, userEvent, within } from "storybook/test";
|
||||
import { deriveProviderStates } from "#/modules/aiModels/providerStates";
|
||||
import { MockChatModelProviderDescriptor } from "#/testHelpers/chatModels";
|
||||
import {
|
||||
MockDefaultOrganization,
|
||||
MockOrganization2,
|
||||
MockOrganization3,
|
||||
MockOrganizationPermissions,
|
||||
} from "#/testHelpers/entities";
|
||||
import { withToaster } from "#/testHelpers/storybook";
|
||||
@@ -23,6 +25,7 @@ const meta: Meta<typeof AddModelPageView> = {
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: MockDefaultOrganization,
|
||||
accessibleOrganizations: [MockDefaultOrganization],
|
||||
permissions: MockOrganizationPermissions,
|
||||
requestedOrganizationDenied: false,
|
||||
}}
|
||||
@@ -56,6 +59,53 @@ export const Default: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
const multiOrgDecorator = (Story: React.FC) => (
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: MockDefaultOrganization,
|
||||
accessibleOrganizations: [
|
||||
MockDefaultOrganization,
|
||||
MockOrganization2,
|
||||
MockOrganization3,
|
||||
],
|
||||
permissions: MockOrganizationPermissions,
|
||||
permissionsByOrganization: {
|
||||
[MockDefaultOrganization.id]: MockOrganizationPermissions,
|
||||
[MockOrganization2.id]: {
|
||||
...MockOrganizationPermissions,
|
||||
createChatModelConfigs: false,
|
||||
},
|
||||
[MockOrganization3.id]: MockOrganizationPermissions,
|
||||
},
|
||||
requestedOrganizationDenied: false,
|
||||
}}
|
||||
>
|
||||
<Story />
|
||||
</OrganizationModelsContext.Provider>
|
||||
);
|
||||
|
||||
export const WithOrganizationPicker: Story = {
|
||||
decorators: [multiOrgDecorator],
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
);
|
||||
await expect(
|
||||
await screen.findByRole("option", {
|
||||
name: MockOrganization3.display_name,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("option", {
|
||||
name: MockOrganization2.display_name,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WebSearchDependentFields: Story = {
|
||||
args: { selectedProviderState: MockAnthropicProviderState },
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -105,18 +155,30 @@ export const ProviderWithoutConfiguredModels: Story = {
|
||||
};
|
||||
|
||||
export const ProviderNotFound: Story = {
|
||||
decorators: [multiOrgDecorator],
|
||||
args: { selectedProviderState: null },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Provider not found")).toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const LoadError: Story = {
|
||||
decorators: [multiOrgDecorator],
|
||||
args: { loadError: new Error("Failed to load models") },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Failed to load models")).toBeVisible();
|
||||
await expect(
|
||||
canvas.getByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import type { FC } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Alert, AlertDescription, AlertTitle } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import type { ProviderState } from "#/modules/aiModels/providerStates";
|
||||
import { ModelForm } from "../components/ModelForm";
|
||||
import { ModelFormBackLink } from "../components/ModelFormHeader";
|
||||
import {
|
||||
creatableModelOrganizations,
|
||||
selectModelOrganizationPath,
|
||||
useOrganizationModels,
|
||||
} from "../organizationModels";
|
||||
|
||||
interface AddModelPageViewProps {
|
||||
isLoading: boolean;
|
||||
@@ -32,6 +39,35 @@ const AddModelPageView: FC<AddModelPageViewProps> = ({
|
||||
onProviderChange,
|
||||
onCreateModel,
|
||||
}) => {
|
||||
const { organization, accessibleOrganizations, permissionsByOrganization } =
|
||||
useOrganizationModels();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const creatableOrganizations = creatableModelOrganizations(
|
||||
accessibleOrganizations,
|
||||
permissionsByOrganization,
|
||||
);
|
||||
const organizationPicker = creatableOrganizations.length > 1 && (
|
||||
<OrganizationField
|
||||
id="add-model-organization"
|
||||
organization={organization}
|
||||
organizations={creatableOrganizations}
|
||||
labelOrganizations={accessibleOrganizations}
|
||||
className="w-60"
|
||||
optionsTabbable
|
||||
onChange={(nextOrganization) => {
|
||||
void navigate(
|
||||
selectModelOrganizationPath(
|
||||
location.pathname,
|
||||
nextOrganization,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <Loader fullscreen />;
|
||||
}
|
||||
@@ -41,6 +77,7 @@ const AddModelPageView: FC<AddModelPageViewProps> = ({
|
||||
<div className="flex flex-col items-start gap-4">
|
||||
<ModelFormBackLink />
|
||||
<ErrorAlert error={loadError} />
|
||||
{organizationPicker}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -56,6 +93,7 @@ const AddModelPageView: FC<AddModelPageViewProps> = ({
|
||||
Please try again.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
{organizationPicker}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ const meta: Meta<typeof ModelsPageView> = {
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: MockDefaultOrganization,
|
||||
accessibleOrganizations: [MockDefaultOrganization],
|
||||
permissions: MockOrganizationPermissions,
|
||||
requestedOrganizationDenied: false,
|
||||
}}
|
||||
@@ -93,9 +94,10 @@ export const Default: Story = {
|
||||
await expect(canvas.getByText("AWS Bedrock")).toBeInTheDocument();
|
||||
// The provider icon is decorative (alt=""), so its name comes from the
|
||||
// visible label asserted above rather than the image alt text.
|
||||
await expect(canvas.getAllByText("Enabled").length).toBeGreaterThan(0);
|
||||
expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
|
||||
await expect(canvas.getByText("Default")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
|
||||
const disabledRow = canvas.getByRole("button", { name: /GPT-4o mini/i });
|
||||
await expect(within(disabledRow).getByText("Disabled")).toBeInTheDocument();
|
||||
|
||||
// The Add model menu lists each provider by exact accessible name; a
|
||||
// regressed icon would turn a name into "Anthropic Anthropic".
|
||||
@@ -183,11 +185,12 @@ export const DisabledProviderModelsStillListed: Story = {
|
||||
// so the row is queried by its clickable role.
|
||||
const row = canvas.getByRole("button", { name: /GPT-4o Secondary/i });
|
||||
await expect(within(row).getByText("OpenAI Secondary")).toBeInTheDocument();
|
||||
// A model under a disabled provider is not usable, so the status
|
||||
// column must show "Disabled" even though the stored enabled flag is
|
||||
// true. Scope to the target row so a fixture change cannot pass this
|
||||
// assertion via an unrelated "Disabled" cell.
|
||||
await expect(within(row).getByText("Disabled")).toBeInTheDocument();
|
||||
// A model under a disabled provider is not usable regardless of its
|
||||
// stored enabled flag; scope to the row so an unrelated cell cannot pass.
|
||||
await expect(
|
||||
within(row).getByRole("button", { name: "Unavailable" }),
|
||||
).toBeInTheDocument();
|
||||
expect(within(row).queryByText("Disabled")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -196,8 +199,8 @@ export const DisabledProviderModelsStillListed: Story = {
|
||||
// models entirely, so the row reaches "Unset" via a map-miss and the
|
||||
// `?? false` fallback at ModelsPageView.tsx wiring. Reproduce that shape
|
||||
// here: the model appears in `models` but is not present in any
|
||||
// providerState.models, so a `?? true` regression would flip this
|
||||
// story to "Enabled" and be caught.
|
||||
// providerState.models, so a `?? true` regression would hide the
|
||||
// unavailable notice and be caught.
|
||||
export const OrphanedModelShowsUnset: Story = {
|
||||
args: {
|
||||
models: [mockGPT5, mockOrphanedModel],
|
||||
@@ -208,7 +211,9 @@ export const OrphanedModelShowsUnset: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
const row = canvas.getByRole("button", { name: /Orphaned Model/i });
|
||||
await expect(within(row).getByText("Unset")).toBeInTheDocument();
|
||||
await expect(within(row).getByText("Disabled")).toBeInTheDocument();
|
||||
await expect(
|
||||
within(row).getByRole("button", { name: "Unavailable" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
InputGroupAddon,
|
||||
InputGroupInput,
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase";
|
||||
import {
|
||||
Select,
|
||||
@@ -47,6 +48,7 @@ import { ModelRow } from "./components/ModelRow";
|
||||
import {
|
||||
organizationAddModelPath,
|
||||
organizationModelPath,
|
||||
selectModelOrganizationPath,
|
||||
useOrganizationModels,
|
||||
} from "./organizationModels";
|
||||
|
||||
@@ -120,7 +122,7 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { organization } = useOrganizationModels();
|
||||
const { organization, accessibleOrganizations } = useOrganizationModels();
|
||||
const [page, setPage] = useState(1);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [providerFilter, setProviderFilter] =
|
||||
@@ -247,6 +249,26 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
||||
/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{accessibleOrganizations.length > 1 && (
|
||||
<OrganizationField
|
||||
id="models-organization"
|
||||
organization={organization}
|
||||
organizations={accessibleOrganizations}
|
||||
showLabel={false}
|
||||
className="w-full sm:w-60"
|
||||
triggerClassName="w-full sm:w-60"
|
||||
optionsTabbable
|
||||
onChange={(nextOrganization) => {
|
||||
void navigate(
|
||||
selectModelOrganizationPath(
|
||||
"/ai/settings/models",
|
||||
nextOrganization,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<Select value={providerFilter} onValueChange={handleProviderChange}>
|
||||
<SelectTrigger
|
||||
className="w-full shadow-none sm:w-60"
|
||||
@@ -273,7 +295,6 @@ const ModelsPageView: FC<ModelsPageViewProps> = ({
|
||||
<TableHead className="w-1/3">Name</TableHead>
|
||||
<TableHead className="w-1/4">Provider</TableHead>
|
||||
<TableHead className="w-1/4">Context limit</TableHead>
|
||||
<TableHead className="w-40">Status</TableHead>
|
||||
<TableHead className="w-12">
|
||||
<span className="sr-only">Open model</span>
|
||||
</TableHead>
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from "#/testHelpers/entities";
|
||||
import { withDashboardProvider } from "#/testHelpers/storybook";
|
||||
import AddModelPage from "./AddModelPage/AddModelPage";
|
||||
import ModelsPage from "./ModelsPage";
|
||||
import OrganizationModelsLayout from "./OrganizationModelsLayout";
|
||||
|
||||
const LocationProbe = () => {
|
||||
@@ -43,7 +44,16 @@ const meta: Meta<typeof OrganizationModelsLayout> = {
|
||||
path: "/ai/settings/models",
|
||||
searchParams: { org: MockDefaultOrganization.name },
|
||||
},
|
||||
routing: [{ path: "*", useStoryElement: true }],
|
||||
routing: [
|
||||
{
|
||||
path: "/ai/settings/models",
|
||||
useStoryElement: true,
|
||||
children: [
|
||||
{ index: true, element: <ModelsPage /> },
|
||||
{ path: "add", element: <AddModelPage /> },
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
queries: [
|
||||
{
|
||||
@@ -102,7 +112,13 @@ export const SwitchOrganizationPreservesAuxiliaryParameters: Story = {
|
||||
duplicate: "model-id",
|
||||
},
|
||||
},
|
||||
routing: [{ path: "*", useStoryElement: true }],
|
||||
routing: [
|
||||
{
|
||||
path: "/ai/settings/models",
|
||||
useStoryElement: true,
|
||||
children: [{ path: "add", element: <AddModelPage /> }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
render: () => (
|
||||
@@ -138,7 +154,13 @@ export const InvalidRequestedOrganizationFallsBackToDefault: Story = {
|
||||
path: "/ai/settings/models",
|
||||
searchParams: { org: "missing" },
|
||||
},
|
||||
routing: [{ path: "*", useStoryElement: true }],
|
||||
routing: [
|
||||
{
|
||||
path: "/ai/settings/models",
|
||||
useStoryElement: true,
|
||||
children: [{ index: true, element: <ModelsPage /> }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
|
||||
@@ -1,18 +1,9 @@
|
||||
import type { FC } from "react";
|
||||
import { useQuery } from "react-query";
|
||||
import {
|
||||
Outlet,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { Outlet, useSearchParams } from "react-router";
|
||||
import { organizationsPermissions } from "#/api/queries/organizations";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Loader } from "#/components/Loader/Loader";
|
||||
import {
|
||||
getOrganizationLabel,
|
||||
OrganizationAutocomplete,
|
||||
} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import NotFoundPage from "#/pages/NotFoundPage/NotFoundPage";
|
||||
import {
|
||||
@@ -24,8 +15,6 @@ import {
|
||||
|
||||
const OrganizationModelsLayout: FC = () => {
|
||||
const { organizations } = useDashboard();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const accessibleOrganizationsQuery =
|
||||
useAccessibleModelOrganizations(organizations);
|
||||
@@ -73,7 +62,10 @@ const OrganizationModelsLayout: FC = () => {
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: activeOrganization,
|
||||
accessibleOrganizations,
|
||||
permissions: activePermissions,
|
||||
permissionsByOrganization:
|
||||
accessibleOrganizationsQuery.permissionsByOrganization,
|
||||
requestedOrganizationDenied:
|
||||
organizationSelection.requestedOrganizationDenied,
|
||||
}}
|
||||
@@ -90,28 +82,6 @@ const OrganizationModelsLayout: FC = () => {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{accessibleOrganizations.length > 1 && (
|
||||
<div>
|
||||
<OrganizationAutocomplete
|
||||
value={activeOrganization}
|
||||
ariaLabel={`Organization ${getOrganizationLabel(
|
||||
activeOrganization,
|
||||
accessibleOrganizations,
|
||||
)}`}
|
||||
options={accessibleOrganizations}
|
||||
triggerClassName="w-60"
|
||||
optionsTabbable
|
||||
onChange={(organization) => {
|
||||
if (!organization) {
|
||||
return;
|
||||
}
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set(modelOrganizationSearchParam, organization.name);
|
||||
void navigate(`${location.pathname}?${next.toString()}`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Outlet />
|
||||
</div>
|
||||
</OrganizationModelsContext.Provider>
|
||||
|
||||
+13
@@ -35,6 +35,7 @@ const meta: Meta<typeof UpdateModelPageView> = {
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: MockDefaultOrganization,
|
||||
accessibleOrganizations: [MockDefaultOrganization],
|
||||
permissions: MockOrganizationPermissions,
|
||||
requestedOrganizationDenied: false,
|
||||
}}
|
||||
@@ -72,6 +73,18 @@ export const Default: Story = {
|
||||
canvas.getByRole("button", { name: /^update model$/i }),
|
||||
).toBeVisible();
|
||||
await expect(canvas.getByLabelText(/model identifier/i)).toBeEnabled();
|
||||
// Switching org while editing would 404 the model, so the organization
|
||||
// renders as a static value rather than a picker.
|
||||
await expect(
|
||||
canvas.getByLabelText(
|
||||
`Organization ${MockDefaultOrganization.display_name}`,
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
canvas.queryByRole("button", {
|
||||
name: `Organization ${MockDefaultOrganization.display_name}`,
|
||||
}),
|
||||
).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ const withOrganizationModels = (Story: React.FC) => (
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: MockDefaultOrganization,
|
||||
accessibleOrganizations: [MockDefaultOrganization],
|
||||
permissions: MockOrganizationPermissions,
|
||||
requestedOrganizationDenied: false,
|
||||
}}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useFormik } from "formik";
|
||||
import { type FC, useRef, useState } from "react";
|
||||
import { useLocation, useNavigate, useSearchParams } from "react-router";
|
||||
import * as Yup from "yup";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt";
|
||||
import {
|
||||
@@ -16,7 +18,11 @@ import {
|
||||
parseThresholdInteger,
|
||||
} from "#/pages/AgentsPage/components/ChatModelAdminPanel/modelConfigFormLogic";
|
||||
import { getFormHelpers } from "#/utils/formUtils";
|
||||
import { useOrganizationModels } from "../organizationModels";
|
||||
import {
|
||||
creatableModelOrganizations,
|
||||
selectModelOrganizationPath,
|
||||
useOrganizationModels,
|
||||
} from "../organizationModels";
|
||||
import { ChatModelSharingDialog } from "./ChatModelSharingDialog";
|
||||
import { ModelFormDialogs } from "./ModelFormDialogs";
|
||||
import { ModelFormFields } from "./ModelFormFields";
|
||||
@@ -84,7 +90,11 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
currentDefaultModel,
|
||||
onToggleEnabled,
|
||||
}) => {
|
||||
const { organization } = useOrganizationModels();
|
||||
const { organization, accessibleOrganizations, permissionsByOrganization } =
|
||||
useOrganizationModels();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const initialModel = editingModel ?? duplicateSourceModel;
|
||||
const isEditing = Boolean(editingModel);
|
||||
const isDuplicating = Boolean(duplicateSourceModel) && !isEditing;
|
||||
@@ -104,6 +114,27 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
const canAddModelForSelectedProvider = canManageProviderModels(
|
||||
selectedProviderState ?? undefined,
|
||||
);
|
||||
const creatableOrganizations = creatableModelOrganizations(
|
||||
accessibleOrganizations,
|
||||
permissionsByOrganization,
|
||||
);
|
||||
const addOrganizationField = creatableOrganizations.length > 1 && (
|
||||
<OrganizationField
|
||||
id="model-form-organization"
|
||||
organization={organization}
|
||||
organizations={creatableOrganizations}
|
||||
optionsTabbable
|
||||
onChange={(nextOrganization) => {
|
||||
void navigate(
|
||||
selectModelOrganizationPath(
|
||||
location.pathname,
|
||||
nextOrganization,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
const mode: "add" | "edit" | "duplicate" = isEditing
|
||||
? "edit"
|
||||
: isDuplicating
|
||||
@@ -283,6 +314,7 @@ export const ModelForm: FC<ModelFormProps> = ({
|
||||
: "Set an API key for this provider before adding models."}
|
||||
</p>
|
||||
)}
|
||||
{addOrganizationField}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import type { FormikContextType } from "formik";
|
||||
import { ChevronDownIcon, ChevronRightIcon, InfoIcon } from "lucide-react";
|
||||
import type { FC, ReactNode } from "react";
|
||||
import { Link as RouterLink } from "react-router";
|
||||
import {
|
||||
Link as RouterLink,
|
||||
useLocation,
|
||||
useNavigate,
|
||||
useSearchParams,
|
||||
} from "react-router";
|
||||
import { getVisibleProviderFields } from "#/api/chatModelOptions";
|
||||
import type * as TypesGen from "#/api/typesGenerated";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
@@ -19,6 +24,7 @@ import {
|
||||
} from "#/components/InputGroup/InputGroup";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { Link } from "#/components/Link/Link";
|
||||
import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -40,7 +46,12 @@ import type {
|
||||
import { cn } from "#/utils/cn";
|
||||
import { docs } from "#/utils/docs";
|
||||
import type { FormHelpers } from "#/utils/formUtils";
|
||||
import { useOrganizationModelsPath } from "../organizationModels";
|
||||
import {
|
||||
creatableModelOrganizations,
|
||||
selectModelOrganizationPath,
|
||||
useOrganizationModels,
|
||||
useOrganizationModelsPath,
|
||||
} from "../organizationModels";
|
||||
import { ModelFormProviderSelect } from "./ModelFormProviderSelect";
|
||||
|
||||
const CollapsibleSection: FC<{
|
||||
@@ -140,6 +151,41 @@ export const ModelFormFields: FC<{
|
||||
setShowAdvanced,
|
||||
}) => {
|
||||
const modelsPath = useOrganizationModelsPath();
|
||||
const { organization, accessibleOrganizations, permissionsByOrganization } =
|
||||
useOrganizationModels();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const selectableOrganizations = isEditing
|
||||
? accessibleOrganizations
|
||||
: creatableModelOrganizations(
|
||||
accessibleOrganizations,
|
||||
permissionsByOrganization,
|
||||
);
|
||||
const organizationField = isEditing ? (
|
||||
<OrganizationField
|
||||
id="model-form-organization"
|
||||
organization={organization}
|
||||
organizations={accessibleOrganizations}
|
||||
readOnly
|
||||
/>
|
||||
) : selectableOrganizations.length > 1 ? (
|
||||
<OrganizationField
|
||||
id="model-form-organization"
|
||||
organization={organization}
|
||||
organizations={selectableOrganizations}
|
||||
optionsTabbable
|
||||
onChange={(nextOrganization) => {
|
||||
void navigate(
|
||||
selectModelOrganizationPath(
|
||||
location.pathname,
|
||||
nextOrganization,
|
||||
searchParams,
|
||||
),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
) : null;
|
||||
const hasProviderConfigFields =
|
||||
getVisibleProviderFields(selectedProviderState.provider).length > 0;
|
||||
|
||||
@@ -249,6 +295,7 @@ export const ModelFormFields: FC<{
|
||||
</InputGroupAddon>
|
||||
</InputGroup>
|
||||
</div>
|
||||
{organizationField}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-solid border-border">
|
||||
|
||||
+1
@@ -17,6 +17,7 @@ const withOrganizationModels = (Story: React.FC) => (
|
||||
<OrganizationModelsContext.Provider
|
||||
value={{
|
||||
organization: MockDefaultOrganization,
|
||||
accessibleOrganizations: [MockDefaultOrganization],
|
||||
permissions: MockOrganizationPermissions,
|
||||
requestedOrganizationDenied: false,
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { Table, TableBody } from "#/components/Table/Table";
|
||||
import { mockClaude, mockGPT5 } from "../testFixtures";
|
||||
import { ModelRow } from "./ModelRow";
|
||||
@@ -32,49 +32,48 @@ const meta: Meta<typeof ModelRow> = {
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof ModelRow>;
|
||||
|
||||
// Control case for the effective-status logic: when both `hasProvider` and
|
||||
// `providerEnabled` are true, the status badge must reflect the persisted
|
||||
// enabled flag as-is. Any regression that inverts this collapses every model
|
||||
// to "Disabled" in the list.
|
||||
// Control case: a healthy, enabled model renders no status badge at all.
|
||||
export const WithProvider: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("OpenAI")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Enabled")).toBeInTheDocument();
|
||||
expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("Disabled")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("Unavailable")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Unset")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
// When the provider is missing (soft-deleted or otherwise unavailable) the
|
||||
// Provider column shows "Unset" and the status collapses to "Disabled" even
|
||||
// though the persisted model.enabled flag is true. An info icon next to the
|
||||
// label reveals a tooltip explaining that the connected provider has been
|
||||
// deleted.
|
||||
// A missing (soft-deleted) provider shows "Unset" plus the "Unavailable"
|
||||
// notice even though the persisted model.enabled flag is true.
|
||||
export const WithoutProviderForcesDisabled: Story = {
|
||||
args: {
|
||||
model: { ...mockClaude, enabled: true },
|
||||
providerLabel: "",
|
||||
hasProvider: false,
|
||||
providerEnabled: false,
|
||||
onClick: fn(),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
play: async ({ args, canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Unset")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
|
||||
expect(canvas.queryByText("Disabled")).not.toBeInTheDocument();
|
||||
|
||||
const info = canvas.getByLabelText("Provider status");
|
||||
await userEvent.hover(info);
|
||||
// The badge is keyboard-focusable and must open its tooltip without
|
||||
// activating the clickable row.
|
||||
const notice = canvas.getByRole("button", { name: "Unavailable" });
|
||||
notice.focus();
|
||||
const tooltip = await within(document.body).findByRole("tooltip");
|
||||
await expect(tooltip).toHaveTextContent(
|
||||
"The provider connected to this model has been deleted.",
|
||||
);
|
||||
await userEvent.keyboard("{Enter}");
|
||||
expect(args.onClick).not.toHaveBeenCalled();
|
||||
},
|
||||
};
|
||||
|
||||
// When the provider exists but is disabled, the label still renders (the
|
||||
// provider is set) but the status collapses to "Disabled" because the model
|
||||
// is not usable.
|
||||
// A disabled provider keeps its label but the model shows the "Unavailable"
|
||||
// notice because it is not usable.
|
||||
export const DisabledProviderForcesDisabled: Story = {
|
||||
args: {
|
||||
model: { ...mockClaude, enabled: true, is_default: false },
|
||||
@@ -85,15 +84,21 @@ export const DisabledProviderForcesDisabled: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Anthropic")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Enabled")).not.toBeInTheDocument();
|
||||
const notice = canvas.getByRole("button", { name: "Unavailable" });
|
||||
await expect(notice).toBeInTheDocument();
|
||||
expect(canvas.queryByText("Disabled")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Unset")).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.hover(notice);
|
||||
const tooltip = await within(document.body).findByRole("tooltip");
|
||||
await expect(tooltip).toHaveTextContent(
|
||||
"The provider connected to this model is disabled.",
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
// A disabled model with an enabled provider keeps its provider label but
|
||||
// stays "Disabled". This exercises the enabled=false path so the "Unset"
|
||||
// wording is only tied to the missing provider case.
|
||||
// enabled=false with a healthy provider: "Disabled" badge beside the name,
|
||||
// no "Unavailable" notice, and no "Unset" wording.
|
||||
export const DisabledModelWithProvider: Story = {
|
||||
args: {
|
||||
model: { ...mockClaude, enabled: false, is_default: false },
|
||||
@@ -104,7 +109,9 @@ export const DisabledModelWithProvider: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.getByText("Anthropic")).toBeInTheDocument();
|
||||
await expect(canvas.getByText("Disabled")).toBeInTheDocument();
|
||||
const nameCell = canvas.getByRole("cell", { name: /Claude Sonnet 4.5/ });
|
||||
await expect(within(nameCell).getByText("Disabled")).toBeInTheDocument();
|
||||
expect(canvas.queryByText("Unavailable")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Unset")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChevronRightIcon, InfoIcon } from "lucide-react";
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import type { ChatModel } from "#/api/typesGenerated";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from "#/components/Tooltip/Tooltip";
|
||||
import { useClickableTableRow } from "#/hooks/useClickableTableRow";
|
||||
import { ProviderIcon } from "#/pages/AISettingsPage/ProvidersPage/components/ProviderIcon";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
type ModelRowProps = {
|
||||
model: ChatModel;
|
||||
@@ -39,8 +40,17 @@ export const ModelRow: FC<ModelRowProps> = ({
|
||||
const clickableProps = useClickableTableRow({ onClick });
|
||||
const displayName = model.display_name || model.model;
|
||||
// Models whose provider is missing or disabled cannot be used, so the
|
||||
// status column reflects that regardless of the persisted enabled flag.
|
||||
const isEffectivelyEnabled = model.enabled && hasProvider && providerEnabled;
|
||||
// status cell surfaces that regardless of the persisted enabled flag.
|
||||
const providerNotice = !hasProvider
|
||||
? "The provider connected to this model has been deleted."
|
||||
: !providerEnabled
|
||||
? "The provider connected to this model is disabled."
|
||||
: null;
|
||||
|
||||
// Keep tooltip activation from triggering the clickable row's navigation.
|
||||
const stopPropagation = (event: React.SyntheticEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow {...clickableProps}>
|
||||
@@ -48,7 +58,10 @@ export const ModelRow: FC<ModelRowProps> = ({
|
||||
<div className="flex min-w-0 items-center gap-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
className="flex shrink-0 items-center justify-center"
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center",
|
||||
!model.enabled && "opacity-50",
|
||||
)}
|
||||
>
|
||||
<ProviderIcon
|
||||
provider={providerTypeByID.get(model.ai_provider_id) ?? ""}
|
||||
@@ -56,7 +69,12 @@ export const ModelRow: FC<ModelRowProps> = ({
|
||||
</Avatar>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span
|
||||
className="truncate text-sm font-medium leading-6 text-content-primary"
|
||||
className={cn(
|
||||
"truncate text-sm font-medium leading-6",
|
||||
model.enabled
|
||||
? "text-content-primary"
|
||||
: "text-content-secondary",
|
||||
)}
|
||||
title={displayName}
|
||||
>
|
||||
{displayName}
|
||||
@@ -66,46 +84,62 @@ export const ModelRow: FC<ModelRowProps> = ({
|
||||
Default
|
||||
</Badge>
|
||||
)}
|
||||
{!model.enabled && (
|
||||
<Badge variant="default" className="shrink-0">
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
{providerNotice && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
asChild
|
||||
variant="warning"
|
||||
className="shrink-0"
|
||||
onClick={stopPropagation}
|
||||
onKeyDown={stopPropagation}
|
||||
onKeyUp={stopPropagation}
|
||||
>
|
||||
<button type="button">Unavailable</button>
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[240px]">
|
||||
{providerNotice}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0">
|
||||
{hasProvider ? (
|
||||
<span
|
||||
className="block truncate text-sm font-medium leading-6 text-content-secondary"
|
||||
className={cn(
|
||||
"block truncate text-sm font-medium leading-6",
|
||||
model.enabled
|
||||
? "text-content-secondary"
|
||||
: "text-content-disabled",
|
||||
)}
|
||||
title={providerLabel}
|
||||
>
|
||||
{providerLabel}
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="truncate text-sm font-medium leading-6 text-content-secondary">
|
||||
Unset
|
||||
</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<InfoIcon
|
||||
aria-label="Provider status"
|
||||
className="size-3 text-content-secondary"
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[240px]">
|
||||
The provider connected to this model has been deleted.
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span className="truncate text-sm font-medium leading-6 text-content-secondary">
|
||||
Unset
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="min-w-0">
|
||||
<span className="block truncate text-sm font-medium leading-6 text-content-secondary">
|
||||
<span
|
||||
className={cn(
|
||||
"block truncate text-sm font-medium leading-6",
|
||||
model.enabled ? "text-content-secondary" : "text-content-disabled",
|
||||
)}
|
||||
>
|
||||
{formatContextLimit(model.context_limit)}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="default">
|
||||
{isEffectivelyEnabled ? "Enabled" : "Disabled"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="w-10 text-center">
|
||||
<div className="flex justify-end items-center gap-8 pr-4">
|
||||
<ChevronRightIcon
|
||||
|
||||
@@ -42,6 +42,7 @@ export const useAccessibleModelOrganizations = (
|
||||
|
||||
return {
|
||||
organizations: accessibleOrganizations,
|
||||
permissionsByOrganization: permissionsQuery.data,
|
||||
isLoading:
|
||||
queries.some((query) => query.isLoading) || permissionsQuery.isLoading,
|
||||
error: hasData ? null : (requestError ?? null),
|
||||
@@ -96,7 +97,11 @@ export const splitModelQueryErrors = (
|
||||
|
||||
type OrganizationModelsContextValue = {
|
||||
organization: Organization;
|
||||
accessibleOrganizations: readonly Organization[];
|
||||
permissions: OrganizationPermissions | undefined;
|
||||
permissionsByOrganization?: Readonly<
|
||||
Record<string, OrganizationPermissions | undefined>
|
||||
>;
|
||||
requestedOrganizationDenied: boolean;
|
||||
};
|
||||
|
||||
@@ -113,6 +118,27 @@ export const useOrganizationModels = (): OrganizationModelsContextValue => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export const selectModelOrganizationPath = (
|
||||
pathname: string,
|
||||
organization: Organization,
|
||||
searchParams?: URLSearchParams,
|
||||
): string => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set(modelOrganizationSearchParam, organization.name);
|
||||
return `${pathname}?${next.toString()}`;
|
||||
};
|
||||
|
||||
export const creatableModelOrganizations = (
|
||||
organizations: readonly Organization[],
|
||||
permissionsByOrganization?: Readonly<
|
||||
Record<string, OrganizationPermissions | undefined>
|
||||
>,
|
||||
): readonly Organization[] =>
|
||||
organizations.filter(
|
||||
(organization) =>
|
||||
permissionsByOrganization?.[organization.id]?.createChatModelConfigs,
|
||||
);
|
||||
|
||||
const organizationModelSettingsPath = (
|
||||
organization: Organization,
|
||||
suffix: string,
|
||||
|
||||
Reference in New Issue
Block a user