diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx index e809a7505bf..329a65d1c22 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.stories.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"; import { action } from "storybook/actions"; -import { userEvent, within } from "storybook/test"; +import { expect, fn, screen, userEvent, waitFor, within } from "storybook/test"; +import type { Organization } from "#/api/typesGenerated"; import { MockOrganization, MockOrganization2, @@ -8,6 +9,8 @@ import { } from "#/testHelpers/entities"; import { OrganizationAutocomplete } from "./OrganizationAutocomplete"; +type OnChangeFn = (org: Organization | null) => void; + const meta: Meta = { title: "components/OrganizationAutocomplete", component: OrganizationAutocomplete, @@ -53,3 +56,142 @@ export const OneOrg: Story = { ], }, }; + +export const PreselectedOrg: Story = { + args: { + organizationId: MockOrganization2.id, + onChange: fn(), + }, + parameters: { + showOrganizations: true, + user: MockUserOwner, + features: ["multiple_organizations"], + permissions: { viewDeploymentConfig: true }, + queries: [ + { + key: ["organizations"], + data: [MockOrganization, MockOrganization2], + }, + ], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await waitFor(() => + expect(button).toHaveTextContent(MockOrganization2.display_name), + ); + const onChangeSpy = args.onChange as ReturnType>; + expect(onChangeSpy).not.toHaveBeenCalled(); + }, +}; + +export const PreselectedOrgNotFound: Story = { + args: { + organizationId: "nonexistent-id", + }, + parameters: { + showOrganizations: true, + user: MockUserOwner, + features: ["multiple_organizations"], + permissions: { viewDeploymentConfig: true }, + queries: [ + { + key: ["organizations"], + data: [MockOrganization, MockOrganization2], + }, + ], + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + // Open the dropdown to verify data has loaded. + await userEvent.click(button); + await waitFor(() => + expect( + screen.getByText(MockOrganization.display_name), + ).toBeInTheDocument(), + ); + // Close and verify the button still shows placeholder + // (the org ID doesn't match any loaded option). + await userEvent.keyboard("{Escape}"); + await waitFor(() => + expect(button).toHaveTextContent("Select an organization"), + ); + }, +}; + +export const PreselectedOrgUserSelects: Story = { + args: { + organizationId: MockOrganization2.id, + onChange: fn(), + }, + parameters: { + showOrganizations: true, + user: MockUserOwner, + features: ["multiple_organizations"], + permissions: { viewDeploymentConfig: true }, + queries: [ + { + key: ["organizations"], + data: [MockOrganization, MockOrganization2], + }, + ], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + // Wait for the preselected org to appear. + await waitFor(() => + expect(button).toHaveTextContent(MockOrganization2.display_name), + ); + const onChangeSpy = args.onChange as ReturnType>; + onChangeSpy.mockClear(); + // Open dropdown and select a different org. + await userEvent.click(button); + await waitFor(() => + expect( + screen.getByText(MockOrganization.display_name), + ).toBeInTheDocument(), + ); + await userEvent.click(screen.getByText(MockOrganization.display_name)); + // Verify onChange was called with the new org. + await waitFor(() => + expect(onChangeSpy).toHaveBeenCalledWith( + expect.objectContaining({ id: MockOrganization.id }), + ), + ); + // Button should still show the prop-controlled value since + // the parent hasn't updated organizationId. + await waitFor(() => + expect(button).toHaveTextContent(MockOrganization2.display_name), + ); + }, +}; + +export const OneOrgWithControlledId: Story = { + args: { + organizationId: MockOrganization.id, + onChange: fn(), + }, + parameters: { + showOrganizations: true, + user: MockUserOwner, + features: ["multiple_organizations"], + permissions: { viewDeploymentConfig: true }, + queries: [ + { + key: ["organizations"], + data: [MockOrganization], + }, + ], + }, + play: async ({ canvasElement, args }) => { + const canvas = within(canvasElement); + const button = canvas.getByRole("button"); + await waitFor(() => + expect(button).toHaveTextContent(MockOrganization.display_name), + ); + const onChangeSpy = args.onChange as ReturnType>; + expect(onChangeSpy).not.toHaveBeenCalled(); + }, +}; diff --git a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx index fd0ca267415..2b023d050d3 100644 --- a/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx +++ b/site/src/components/OrganizationAutocomplete/OrganizationAutocomplete.tsx @@ -26,6 +26,20 @@ type OrganizationAutocompleteProps = { id?: string; required?: boolean; check?: AuthorizationCheck; + /** + * Pre-selects an organization by ID. When provided, the + * displayed selection is derived from this prop. The parent + * is responsible for updating this prop in response to user + * selections via onChange. + * + * When combined with `check`, the ID must reference an org + * the user is authorized for — if the org fails the check, + * the button silently shows placeholder text without firing + * onChange(null). The follow-up full-object refactor + * (https://github.com/coder/internal/issues/1440) will + * address this. + */ + organizationId?: string; }; export const OrganizationAutocomplete: FC = ({ @@ -33,6 +47,7 @@ export const OrganizationAutocomplete: FC = ({ id, required, check, + organizationId, }) => { const [open, setOpen] = useState(false); const [selected, setSelected] = useState(null); @@ -66,18 +81,31 @@ export const OrganizationAutocomplete: FC = ({ : []; } - // Unfortunate: this useEffect sets a default org value - // if only one is available and is necessary as the autocomplete loads - // its own data. Until we refactor, proceed cautiously! + // In controlled mode, derive the displayed selection from the + // prop so we never need to sync prop → state via an effect. + // Note: when `check` is also provided, options may be empty + // until permissions load, causing a brief placeholder flash. + // The follow-up refactor (passing the full Organization object + // instead of just an ID) will eliminate this. + const displayedSelection = organizationId + ? (options.find((o) => o.id === organizationId) ?? null) + : selected; + + // Auto-select when only one option exists. Only active in + // uncontrolled mode — when the parent controls the value via + // organizationId it is responsible for the initial selection. useEffect(() => { + if (organizationId) { + return; + } const org = options[0]; - if (options.length !== 1 || org === selected) { + if (options.length !== 1 || org.id === selected?.id) { return; } setSelected(org); onChange(org); - }, [options, selected, onChange]); + }, [options, selected, onChange, organizationId]); return ( @@ -90,14 +118,16 @@ export const OrganizationAutocomplete: FC = ({ data-testid="organization-autocomplete" className="w-full justify-start gap-2 font-normal" > - {selected ? ( + {displayedSelection ? ( <> - {selected.display_name} + + {displayedSelection.display_name} + ) : ( @@ -121,7 +151,12 @@ export const OrganizationAutocomplete: FC = ({ key={org.id} value={`${org.display_name} ${org.name}`} onSelect={() => { - setSelected(org); + // Only update internal state in uncontrolled mode. + // In controlled mode, displayedSelection is derived + // from the organizationId prop. + if (!organizationId) { + setSelected(org); + } onChange(org); setOpen(false); }} @@ -134,7 +169,7 @@ export const OrganizationAutocomplete: FC = ({ {org.display_name || org.name} - {selected?.id === org.id && ( + {displayedSelection?.id === org.id && ( )}