mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user