refactor(site): convert OrganizationAutocomplete to fully controlled component (#24211)

Fixes https://github.com/coder/internal/issues/1440

- Convert `OrganizationAutocomplete` to a purely presentational, fully
controlled component
- Accept `value`, `onChange`, `options` from parent; remove internal
state, data fetching, and permission filtering
- Update `CreateTemplateForm` and `CreateUserForm` to own org fetching,
permission checks, auto-select, and invalid-value clearing inline
- Memoize `orgOptions` in callers for stable `useEffect` deps
- Rewrite Storybook stories for the new controlled API


> 🤖 Written by a Coder Agent. Reviewed by a human.
This commit is contained in:
Cian Johnston
2026-04-10 13:56:43 +01:00
committed by GitHub
parent 2c32d84f12
commit 0a14bb529e
8 changed files with 306 additions and 178 deletions
+119
View File
@@ -0,0 +1,119 @@
import { describe, expect, it, vi } from "vitest";
import { API } from "#/api/api";
import type { AuthorizationCheck, Organization } from "#/api/typesGenerated";
import { permittedOrganizations } from "./organizations";
// Mock the API module
vi.mock("#/api/api", () => ({
API: {
getOrganizations: vi.fn(),
checkAuthorization: vi.fn(),
},
}));
const MockOrg1: Organization = {
id: "org-1",
name: "org-one",
display_name: "Org One",
description: "",
icon: "",
created_at: "",
updated_at: "",
is_default: true,
};
const MockOrg2: Organization = {
id: "org-2",
name: "org-two",
display_name: "Org Two",
description: "",
icon: "",
created_at: "",
updated_at: "",
is_default: false,
};
const templateCreateCheck: AuthorizationCheck = {
object: { resource_type: "template" },
action: "create",
};
describe("permittedOrganizations", () => {
it("returns query config with correct queryKey", () => {
const config = permittedOrganizations(templateCreateCheck);
expect(config.queryKey).toEqual([
"organizations",
"permitted",
templateCreateCheck,
]);
});
it("fetches orgs and filters by permission check", async () => {
const getOrgsMock = vi.mocked(API.getOrganizations);
const checkAuthMock = vi.mocked(API.checkAuthorization);
getOrgsMock.mockResolvedValue([MockOrg1, MockOrg2]);
checkAuthMock.mockResolvedValue({
"org-1": true,
"org-2": false,
});
const config = permittedOrganizations(templateCreateCheck);
const result = await config.queryFn!();
// Should only return org-1 (which passed the check)
expect(result).toEqual([MockOrg1]);
// Verify the auth check was called with per-org checks
expect(checkAuthMock).toHaveBeenCalledWith({
checks: {
"org-1": {
...templateCreateCheck,
object: {
...templateCreateCheck.object,
organization_id: "org-1",
},
},
"org-2": {
...templateCreateCheck,
object: {
...templateCreateCheck.object,
organization_id: "org-2",
},
},
},
});
});
it("returns all orgs when all pass the check", async () => {
const getOrgsMock = vi.mocked(API.getOrganizations);
const checkAuthMock = vi.mocked(API.checkAuthorization);
getOrgsMock.mockResolvedValue([MockOrg1, MockOrg2]);
checkAuthMock.mockResolvedValue({
"org-1": true,
"org-2": true,
});
const config = permittedOrganizations(templateCreateCheck);
const result = await config.queryFn!();
expect(result).toEqual([MockOrg1, MockOrg2]);
});
it("returns empty array when no orgs pass the check", async () => {
const getOrgsMock = vi.mocked(API.getOrganizations);
const checkAuthMock = vi.mocked(API.checkAuthorization);
getOrgsMock.mockResolvedValue([MockOrg1, MockOrg2]);
checkAuthMock.mockResolvedValue({
"org-1": false,
"org-2": false,
});
const config = permittedOrganizations(templateCreateCheck);
const result = await config.queryFn!();
expect(result).toEqual([]);
});
});
+27 -1
View File
@@ -5,6 +5,7 @@ import {
type GetProvisionerJobsParams,
} from "#/api/api";
import type {
AuthorizationCheck,
CreateOrganizationRequest,
GroupSyncSettings,
Organization,
@@ -160,7 +161,7 @@ export const updateOrganizationMemberRoles = (
};
};
export const organizationsKey = ["organizations"] as const;
const organizationsKey = ["organizations"] as const;
const notAvailable = { available: false, value: undefined } as const;
@@ -295,6 +296,31 @@ export const provisionerJobs = (
};
};
/**
* Fetch organizations the current user is permitted to use for a given
* action. Fetches all organizations, runs a per-org authorization
* check, and returns only those that pass.
*/
export const permittedOrganizations = (check: AuthorizationCheck) => {
return {
queryKey: ["organizations", "permitted", check],
queryFn: async (): Promise<Organization[]> => {
const orgs = await API.getOrganizations();
const checks = Object.fromEntries(
orgs.map((org) => [
org.id,
{
...check,
object: { ...check.object, organization_id: org.id },
},
]),
);
const permissions = await API.checkAuthorization({ checks });
return orgs.filter((org) => permissions[org.id]);
},
};
};
/**
* Fetch permissions for all provided organizations.
*