From a16b6c4516e82ce8eada59d9fdc07026f1b7a63a Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Tue, 25 Aug 2026 15:09:59 +0000 Subject: [PATCH 01/19] feat(site): reposition org pickers in AI settings models and MCP pages --- .../AddMCPServerPageView.stories.tsx | 6 +- .../AddMCPServerPage/AddMCPServerPageView.tsx | 44 ++++++++----- .../MCPServersPage/MCPServersPage.stories.tsx | 56 ++++++++++++++-- .../MCPServersPage/MCPServersPageView.tsx | 60 ++++++++++++++--- .../UpdateMCPServerPageView.stories.tsx | 6 +- .../UpdateMCPServerPageView.tsx | 16 +++-- .../components/MCPServerForm.tsx | 6 +- .../components/MCPServerFormFields.tsx | 20 +++++- .../components/OrganizationPicker.tsx | 55 +++++++++++----- .../AddModelPage/AddModelPageView.stories.tsx | 1 + .../AddModelPage/AddModelPageView.tsx | 2 + .../ModelsPage/ModelsPageView.stories.tsx | 1 + .../ModelsPage/ModelsPageView.tsx | 2 + .../OrganizationModelsLayout.stories.tsx | 28 +++++++- .../ModelsPage/OrganizationModelsLayout.tsx | 36 +---------- .../UpdateModelPageView.stories.tsx | 1 + .../components/ModelForm.stories.tsx | 58 +++++++++++++++++ .../ModelsPage/components/ModelForm.tsx | 2 + .../ModelsPage/components/ModelFormFields.tsx | 2 + .../ModelFormProviderConfig.stories.tsx | 1 + .../components/ModelOrganizationSelect.tsx | 64 +++++++++++++++++++ .../ModelsPage/organizationModels.tsx | 1 + 22 files changed, 369 insertions(+), 99 deletions(-) create mode 100644 site/src/pages/AISettingsPage/ModelsPage/components/ModelOrganizationSelect.tsx diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx index 66b3d67726a..27a6acdde48 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.stories.tsx @@ -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"); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx index 8582d61fb0e..5fa3a7ee857 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/AddMCPServerPage/AddMCPServerPageView.tsx @@ -34,15 +34,6 @@ const AddMCPServerPageView: FC = ({ return ( <> Codestin Search App - {canCreate ? ( = ({ } isSaving={isSaving} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={ + + } onCreateServer={onCreateServer} onCancel={canViewServerList ? onCancel : undefined} /> ) : ( - - You cannot add servers to this organization - - Choose an organization where you have permission to add MCP servers. - - + <> + + + You cannot add servers to this organization + + Choose an organization where you have permission to add MCP + servers. + + + )} ); diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx index 409526712a5..b2137a2427b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPage.stories.tsx @@ -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 = { @@ -538,15 +538,61 @@ 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(); + await expect( + canvas.getByRole("button", { + name: `Organization ${MockDefaultOrganization.display_name}`, + }), + ).toBeVisible(); + expect(canvas.queryByText("Organization")).not.toBeInTheDocument(); + + 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], diff --git a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx index a81e5217792..ad342099dea 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/MCPServersPageView.tsx @@ -1,9 +1,14 @@ -import { PlusIcon } from "lucide-react"; -import type { FC } from "react"; +import { PlusIcon, SearchIcon } from "lucide-react"; +import { type FC, useMemo, 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,19 @@ const MCPServersPageView: FC = ({ onSelectOrganization, }) => { const navigate = useNavigate(); + const [searchQuery, setSearchQuery] = useState(""); + const filteredServers = useMemo(() => { + const normalizedQuery = searchQuery.trim().toLowerCase(); + if (normalizedQuery.length === 0) { + return servers; + } + return servers.filter((server) => + [server.display_name, server.slug, server.url] + .join(" ") + .toLowerCase() + .includes(normalizedQuery), + ); + }, [servers, searchQuery]); // Disambiguate against every organization sharing the page context: // other creation targets and the currently selected organization. const addButtonLabel = @@ -85,13 +103,30 @@ const MCPServersPageView: FC = ({ Agents. - +
+
+ + + + + setSearchQuery(event.target.value)} + /> + +
+ +
{Boolean(error) && (
@@ -130,8 +165,13 @@ const MCPServersPageView: FC = ({ ) : undefined } /> + ) : filteredServers.length === 0 ? ( + ) : ( - servers.map((server) => ( + filteredServers.map((server) => ( = ({ return ( <> Codestin Search App - = ({ isSaving={isSaving} isDeleting={isDeleting} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={ + + } onUpdateServer={onUpdateServer} onDeleteServer={onDeleteServer} onToggleEnabled={onToggleEnabled} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx index f7066458779..60b496db08c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerForm.tsx @@ -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"; @@ -21,6 +21,7 @@ type MCPServerFormCreateProps = { isSaving: boolean; isDeleting?: false; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCreateServer: ( req: TypesGen.CreateMCPServerConfigRequest, ) => Promise; @@ -36,6 +37,7 @@ type MCPServerFormEditProps = { isSaving: boolean; isDeleting: boolean; canSelectUserOIDC: boolean; + organizationPicker?: ReactNode; onCreateServer?: undefined; onUpdateServer?: ( serverId: string, @@ -54,6 +56,7 @@ export const MCPServerForm: FC = ({ isSaving, isDeleting = false, canSelectUserOIDC, + organizationPicker, onCreateServer, onUpdateServer, onDeleteServer, @@ -120,6 +123,7 @@ export const MCPServerForm: FC = ({ canSubmit={canSubmit} isEditing={isEditing} canSelectUserOIDC={canSelectUserOIDC} + organizationPicker={organizationPicker} onCancel={onCancel} showDetails={showDetails} setShowDetails={setShowDetails} diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx index 99f8aa0f99f..f7b54bf043c 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/MCPServerFormFields.tsx @@ -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 = ({ canSubmit, isEditing, canSelectUserOIDC, + organizationPicker, onCancel, showDetails, setShowDetails, @@ -65,7 +68,12 @@ export const MCPServerFormFields: FC = ({ autoComplete="off" className="flex flex-col gap-6" > -
+
= ({ disabled={isDisabled} /> -
+ {organizationPicker} +
void; className?: string; disabled?: boolean; + showLabel?: boolean; showSingleOrganization?: boolean; } @@ -24,6 +26,7 @@ export const OrganizationPicker: FC = ({ onChange, className, disabled, + showLabel = true, showSingleOrganization = false, }) => { const hasSingleSelectedOrganization = @@ -41,24 +44,46 @@ export const OrganizationPicker: FC = ({ ) ? organizations : [...organizations, organization]; + const organizationLabel = getOrganizationLabel( + organization, + labelOrganizations, + ); + // A picker without a change handler or alternatives is informational, so + // render a static value instead of a disabled control with muted text. + const isReadOnly = !onChange || hasSingleSelectedOrganization; return (
- - { - if (org) { - onChange?.(org); - } - }} - options={organizations} - labelOrganizations={labelOrganizations} - required - disabled={disabled || !onChange || hasSingleSelectedOrganization} - /> + {showLabel && } + {isReadOnly ? ( + + + {organizationLabel} + + ) : ( + { + if (org) { + onChange?.(org); + } + }} + options={organizations} + labelOrganizations={labelOrganizations} + required + disabled={disabled} + /> + )}
); }; diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx index 0fb858b0094..b9385e2c4f7 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.stories.tsx @@ -23,6 +23,7 @@ const meta: Meta = { = ({ Please try again. +
); } diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx index 924ab98fe2c..a154452569d 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.stories.tsx @@ -29,6 +29,7 @@ const meta: Meta = { = ({ />
+ /^[aeiou]/i.test(word) ? "an" : "a"; @@ -85,7 +94,11 @@ export const ModelForm: FC = ({ 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; @@ -105,6 +118,41 @@ export const ModelForm: FC = ({ const canAddModelForSelectedProvider = canManageProviderModels( selectedProviderState ?? undefined, ); + const creatableOrganizations = creatableModelOrganizations( + accessibleOrganizations, + permissionsByOrganization, + ); + const addOrganizationField = creatableOrganizations.length > 1 && ( +
+ + { + if (nextOrganization) { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + } + }} + /> +
+ ); const mode: "add" | "edit" | "duplicate" = isEditing ? "edit" : isDuplicating @@ -284,10 +332,7 @@ export const ModelForm: FC = ({ : "Set an API key for this provider before adding models."}

)} - + {addOrganizationField}
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx index 976f0bc5a9b..784ee8a554d 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx @@ -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,11 @@ import { } from "#/components/InputGroup/InputGroup"; import { Label } from "#/components/Label/Label"; import { Link } from "#/components/Link/Link"; +import { + getOrganizationLabel, + OrganizationAutocomplete, + OrganizationValue, +} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import { Spinner } from "#/components/Spinner/Spinner"; import { Tooltip, @@ -40,9 +50,13 @@ 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"; -import { ModelOrganizationSelect } from "./ModelOrganizationSelect"; const CollapsibleSection: FC<{ title: string; @@ -141,6 +155,62 @@ 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 ? ( +
+ + +
+ ) : selectableOrganizations.length > 1 ? ( +
+ + { + if (nextOrganization) { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + } + }} + /> +
+ ) : null; const hasProviderConfigFields = getVisibleProviderFields(selectedProviderState.provider).length > 0; @@ -250,11 +320,7 @@ export const ModelFormFields: FC<{ - + {organizationField}
diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelOrganizationSelect.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelOrganizationSelect.tsx deleted file mode 100644 index 6deff977fd3..00000000000 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelOrganizationSelect.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { type FC, useId } from "react"; -import { useLocation, useNavigate, useSearchParams } from "react-router"; -import { Label } from "#/components/Label/Label"; -import { - getOrganizationLabel, - OrganizationAutocomplete, - OrganizationValue, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; -import { - modelOrganizationSearchParam, - useOrganizationModels, -} from "../organizationModels"; - -// Switches the active organization via the org search param. -export const ModelOrganizationSelect: FC<{ - label?: string; - readOnly?: boolean; - requireCreatePermission?: boolean; - triggerClassName?: string; -}> = ({ - label, - readOnly = false, - requireCreatePermission = false, - triggerClassName, -}) => { - const { organization, accessibleOrganizations, permissionsByOrganization } = - useOrganizationModels(); - const selectableOrganizations = requireCreatePermission - ? accessibleOrganizations.filter( - (organization) => - permissionsByOrganization?.[organization.id]?.createChatModelConfigs, - ) - : accessibleOrganizations; - const location = useLocation(); - const navigate = useNavigate(); - const [searchParams] = useSearchParams(); - const id = useId(); - - if (!readOnly && selectableOrganizations.length <= 1) { - return null; - } - - const autocomplete = readOnly ? ( - - ) : ( - { - if (!nextOrganization) { - return; - } - const next = new URLSearchParams(searchParams); - next.set(modelOrganizationSearchParam, nextOrganization.name); - void navigate(`${location.pathname}?${next.toString()}`); - }} - /> - ); - - if (!label) { - return autocomplete; - } - - return ( -
- - {autocomplete} -
- ); -}; diff --git a/site/src/pages/AISettingsPage/ModelsPage/organizationModels.tsx b/site/src/pages/AISettingsPage/ModelsPage/organizationModels.tsx index 0c0ec923534..7a4033b6e1f 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/organizationModels.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/organizationModels.tsx @@ -118,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 + >, +): readonly Organization[] => + organizations.filter( + (organization) => + permissionsByOrganization?.[organization.id]?.createChatModelConfigs, + ); + const organizationModelSettingsPath = ( organization: Organization, suffix: string, From bf56091a2dc45e6e801c63346b4b990326b7484e Mon Sep 17 00:00:00 2001 From: Tracy Johnson Date: Wed, 26 Aug 2026 18:47:30 +0000 Subject: [PATCH 19/19] refactor(site/src/components): share organization field presentation --- .../OrganizationAutocomplete.tsx | 93 ++++++++++++++++++- .../components/OrganizationPicker.tsx | 78 +++------------- .../AddModelPage/AddModelPageView.tsx | 53 ++++------- .../ModelsPage/ModelsPageView.tsx | 34 +++---- .../ModelsPage/components/ModelForm.tsx | 50 ++++------ .../ModelsPage/components/ModelFormFields.tsx | 69 +++++--------- 6 files changed, 174 insertions(+), 203 deletions(-) diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index fc8cffe6fc7..e36fa352611 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -12,6 +12,7 @@ import { CommandItem, CommandList, } from "#/components/Command/Command"; +import { Label } from "#/components/Label/Label"; import { Popover, PopoverContent, @@ -162,8 +163,7 @@ type OrganizationValueProps = { className?: string; }; -// Static read-only counterpart to OrganizationAutocomplete. -export const OrganizationValue: FC = ({ +const OrganizationValue: FC = ({ organization, labelOrganizations, id, @@ -192,3 +192,92 @@ export const OrganizationValue: FC = ({
); }; + +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 = ({ + 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 ( +
+ {showLabel && ( + + )} + {isReadOnly ? ( + + ) : ( + { + if (org) { + onChange?.(org); + } + }} + options={organizations} + labelOrganizations={resolvedLabelOrganizations} + required={required} + disabled={disabled} + triggerClassName={triggerClassName} + optionsTabbable={optionsTabbable} + /> + )} +
+ ); +}; diff --git a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx index 25e45e5998d..c3534d0aa4b 100644 --- a/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx +++ b/site/src/pages/AISettingsPage/MCPServersPage/components/OrganizationPicker.tsx @@ -1,12 +1,6 @@ import type { FC } from "react"; import type { Organization } from "#/api/typesGenerated"; -import { Label } from "#/components/Label/Label"; -import { - getOrganizationLabel, - OrganizationAutocomplete, - OrganizationValue, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; -import { cn } from "#/utils/cn"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; interface OrganizationPickerProps { id: string; @@ -28,61 +22,15 @@ export const OrganizationPicker: FC = ({ 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]; - const organizationLabel = getOrganizationLabel( - organization, - labelOrganizations, - ); - // A picker without a change handler or alternatives is informational. - const isReadOnly = !onChange || hasSingleSelectedOrganization; - - return ( -
- {showLabel && ( - - )} - {isReadOnly ? ( - - ) : ( - { - if (org) { - onChange?.(org); - } - }} - options={organizations} - labelOrganizations={labelOrganizations} - required - disabled={disabled} - /> - )} -
- ); -}; +}) => ( + +); diff --git a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx index bb891962503..a82518f7030 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/AddModelPage/AddModelPageView.tsx @@ -3,12 +3,8 @@ 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 { Label } from "#/components/Label/Label"; import { Loader } from "#/components/Loader/Loader"; -import { - getOrganizationLabel, - OrganizationAutocomplete, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import type { ProviderState } from "#/modules/aiModels/providerStates"; import { ModelForm } from "../components/ModelForm"; import { ModelFormBackLink } from "../components/ModelFormHeader"; @@ -53,36 +49,23 @@ const AddModelPageView: FC = ({ permissionsByOrganization, ); const organizationPicker = creatableOrganizations.length > 1 && ( -
- - { - if (nextOrganization) { - void navigate( - selectModelOrganizationPath( - location.pathname, - nextOrganization, - searchParams, - ), - ); - } - }} - /> -
+ { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + }} + /> ); if (isLoading) { diff --git a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx index 532b30b811a..8fa019927fb 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/ModelsPageView.tsx @@ -15,10 +15,7 @@ import { InputGroupAddon, InputGroupInput, } from "#/components/InputGroup/InputGroup"; -import { - getOrganizationLabel, - OrganizationAutocomplete, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import { PaginationWidgetBase } from "#/components/PaginationWidget/PaginationWidgetBase"; import { Select, @@ -253,25 +250,22 @@ const ModelsPageView: FC = ({ {accessibleOrganizations.length > 1 && ( - { - if (nextOrganization) { - void navigate( - selectModelOrganizationPath( - "/ai/settings/models", - nextOrganization, - searchParams, - ), - ); - } + void navigate( + selectModelOrganizationPath( + "/ai/settings/models", + nextOrganization, + searchParams, + ), + ); }} /> )} diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx index 06f90e9659b..14a9c68f141 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelForm.tsx @@ -3,11 +3,7 @@ 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 { Label } from "#/components/Label/Label"; -import { - getOrganizationLabel, - OrganizationAutocomplete, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import { SettingsHeaderTitle } from "#/components/SettingsHeader/SettingsHeader"; import { useUnsavedChangesPrompt } from "#/hooks/useUnsavedChangesPrompt"; import { @@ -123,35 +119,21 @@ export const ModelForm: FC = ({ permissionsByOrganization, ); const addOrganizationField = creatableOrganizations.length > 1 && ( -
- - { - if (nextOrganization) { - void navigate( - selectModelOrganizationPath( - location.pathname, - nextOrganization, - searchParams, - ), - ); - } - }} - /> -
+ { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + }} + /> ); const mode: "add" | "edit" | "duplicate" = isEditing ? "edit" diff --git a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx index 784ee8a554d..5301b6672ec 100644 --- a/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx +++ b/site/src/pages/AISettingsPage/ModelsPage/components/ModelFormFields.tsx @@ -24,11 +24,7 @@ import { } from "#/components/InputGroup/InputGroup"; import { Label } from "#/components/Label/Label"; import { Link } from "#/components/Link/Link"; -import { - getOrganizationLabel, - OrganizationAutocomplete, - OrganizationValue, -} from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; +import { OrganizationField } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete"; import { Spinner } from "#/components/Spinner/Spinner"; import { Tooltip, @@ -167,49 +163,28 @@ export const ModelFormFields: FC<{ permissionsByOrganization, ); const organizationField = isEditing ? ( -
- - -
+ ) : selectableOrganizations.length > 1 ? ( -
- - { - if (nextOrganization) { - void navigate( - selectModelOrganizationPath( - location.pathname, - nextOrganization, - searchParams, - ), - ); - } - }} - /> -
+ { + void navigate( + selectModelOrganizationPath( + location.pathname, + nextOrganization, + searchParams, + ), + ); + }} + /> ) : null; const hasProviderConfigFields = getVisibleProviderFields(selectedProviderState.provider).length > 0;