mirror of
https://github.com/coder/coder.git
synced 2026-09-01 14:53:15 +08:00
refactor(site): modernize create organization page (#27914)
> 🤖 This PR was modified by Coder Agents on behalf of Jake Howell. Modernizes the create organization page to match the AI Providers / OAuth2 create-page pattern. The page is now a thin permission shell; the view owns the create mutation, uses `SettingsHeader` with a live icon preview, `IconPickerField`, field descriptions, and a bordered form card in a centered container. Storybook play coverage and e2e selectors are updated for the new submit label. Unentitled deployments still hit `PaywallPremium`. | Old | New | | --- | --- | | <img width="1624" height="1061" alt="image" src="https://github.com/user-attachments/assets/348937f2-7b35-467b-8328-e21bfc4f42f5" /> | <img width="1624" height="1061" alt="image" src="https://github.com/user-attachments/assets/1754f5cb-b005-4536-8f0d-e31ba0b0cca3" /> | | <img width="619" height="1061" alt="image" src="https://github.com/user-attachments/assets/92b69187-77f4-4a48-a229-cc02beb00f1d" /> | <img width="619" height="1061" alt="image" src="https://github.com/user-attachments/assets/db5c1a98-446b-4541-b4a2-39ccb424de58" /> |
This commit is contained in:
+1
-1
@@ -1407,7 +1407,7 @@ export async function createOrganization(page: Page): Promise<{
|
||||
const description = `Org description ${name}`;
|
||||
await page.getByLabel("Description").fill(description);
|
||||
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png");
|
||||
await page.getByRole("button", { name: /save/i }).click();
|
||||
await page.getByRole("button", { name: /create organization/i }).click();
|
||||
|
||||
await expectUrl(page).toHavePathName(`/organizations/${name}`);
|
||||
await expect(page.getByText(/created successfully/)).toBeVisible();
|
||||
|
||||
@@ -23,7 +23,7 @@ test("create and delete organization", async ({ page }) => {
|
||||
await page.getByLabel("Display name").fill(`Org ${name}`);
|
||||
await page.getByLabel("Description").fill(`Org description ${name}`);
|
||||
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png");
|
||||
await page.getByRole("button", { name: /save/i }).click();
|
||||
await page.getByRole("button", { name: /create organization/i }).click();
|
||||
|
||||
// Expect to be redirected to the new organization
|
||||
await expectUrl(page).toHavePathName(`/organizations/${name}`);
|
||||
|
||||
@@ -1,42 +1,22 @@
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import { createOrganization } from "#/api/queries/organizations";
|
||||
import { useAuthenticated } from "#/hooks/useAuthenticated";
|
||||
import { useFeatureVisibility } from "#/modules/dashboard/useFeatureVisibility";
|
||||
import { RequirePermission } from "#/modules/permissions/RequirePermission";
|
||||
import { pageTitle } from "#/utils/page";
|
||||
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";
|
||||
|
||||
const CreateOrganizationPage: FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const feats = useFeatureVisibility();
|
||||
const { permissions } = useAuthenticated();
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const createOrganizationMutation = useMutation(
|
||||
createOrganization(queryClient),
|
||||
);
|
||||
|
||||
const error = createOrganizationMutation.error;
|
||||
|
||||
return (
|
||||
<div className="py-7">
|
||||
<RequirePermission isFeatureVisible={permissions.createOrganization}>
|
||||
<CreateOrganizationPageView
|
||||
error={error}
|
||||
isEntitled={feats.multiple_organizations}
|
||||
permissions={permissions}
|
||||
onSubmit={async (values) => {
|
||||
await createOrganizationMutation.mutateAsync(values);
|
||||
toast.success(
|
||||
`Organization "${values.name}" created successfully.`,
|
||||
);
|
||||
navigate(`/organizations/${values.name}`);
|
||||
}}
|
||||
/>
|
||||
</RequirePermission>
|
||||
</div>
|
||||
<RequirePermission isFeatureVisible={permissions.createOrganization}>
|
||||
<title>{pageTitle("New Organization")}</title>
|
||||
<CreateOrganizationPageView
|
||||
isEntitled={feats.multiple_organizations}
|
||||
permissions={permissions}
|
||||
/>
|
||||
</RequirePermission>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,34 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, screen, userEvent, within } from "storybook/test";
|
||||
import { MockPermissions, mockApiError } from "#/testHelpers/entities";
|
||||
import { expect, spyOn, userEvent, within } from "storybook/test";
|
||||
import { reactRouterParameters } from "storybook-addon-remix-react-router";
|
||||
import { API } from "#/api/api";
|
||||
import {
|
||||
MockOrganization,
|
||||
MockPermissions,
|
||||
mockApiError,
|
||||
} from "#/testHelpers/entities";
|
||||
import { withToaster } from "#/testHelpers/storybook";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";
|
||||
|
||||
const meta: Meta<typeof CreateOrganizationPageView> = {
|
||||
title: "pages/CreateOrganizationPageView",
|
||||
title: "pages/OrganizationSettingsPage/CreateOrganizationPage",
|
||||
component: CreateOrganizationPageView,
|
||||
decorators: [withToaster],
|
||||
args: {
|
||||
isEntitled: true,
|
||||
permissions: MockPermissions,
|
||||
},
|
||||
parameters: {
|
||||
reactRouter: reactRouterParameters({
|
||||
location: { path: "/organizations/new" },
|
||||
routing: [
|
||||
{ path: "/organizations", useStoryElement: true },
|
||||
{ path: "/organizations/new", useStoryElement: true },
|
||||
{ path: "/organizations/:organization", useStoryElement: true },
|
||||
],
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
@@ -19,12 +37,12 @@ type Story = StoryObj<typeof CreateOrganizationPageView>;
|
||||
export const Example: Story = {
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
// The badge is passive: hovering it must not surface a paywall.
|
||||
await userEvent.hover(canvas.getByText("Premium"));
|
||||
await expect(
|
||||
screen.queryByRole("link", { name: "Learn more about premium" }),
|
||||
).not.toBeInTheDocument();
|
||||
canvas.getByRole("heading", { name: "New Organization" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.getByRole("form", { name: "Organization settings form" }),
|
||||
).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -34,7 +52,12 @@ export const NotEntitled: Story = {
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
|
||||
await expect(
|
||||
canvas.getByRole("heading", { name: "New Organization" }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
canvas.queryByRole("form", { name: "Organization settings form" }),
|
||||
).not.toBeInTheDocument();
|
||||
await expect(
|
||||
canvas.getByRole("link", { name: /View docs/ }),
|
||||
).toHaveAttribute("href", docs("/admin/users/organizations"));
|
||||
@@ -61,19 +84,76 @@ export const NotEntitledWithoutLicenseAccess: Story = {
|
||||
};
|
||||
|
||||
export const WithError: Story = {
|
||||
args: { error: "Oh no!" },
|
||||
beforeEach: () => {
|
||||
spyOn(API, "createOrganization").mockRejectedValue(
|
||||
mockApiError({ message: "Oh no!" }),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(canvas.getByLabelText(/slug/i), "new-org");
|
||||
await user.click(
|
||||
canvas.getByRole("button", { name: "Create organization" }),
|
||||
);
|
||||
|
||||
const alert = await canvas.findByRole("alert");
|
||||
await expect(within(alert).getByText("Oh no!")).toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const InvalidName: Story = {
|
||||
args: {
|
||||
error: mockApiError({
|
||||
message: "Display name is bad",
|
||||
validations: [
|
||||
{
|
||||
field: "display_name",
|
||||
detail: "That display name is terrible. What were you thinking?",
|
||||
},
|
||||
],
|
||||
}),
|
||||
beforeEach: () => {
|
||||
spyOn(API, "createOrganization").mockRejectedValue(
|
||||
mockApiError({
|
||||
message: "Display name is bad",
|
||||
validations: [
|
||||
{
|
||||
field: "display_name",
|
||||
detail: "That display name is terrible. What were you thinking?",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(canvas.getByLabelText(/slug/i), "new-org");
|
||||
await user.type(canvas.getByLabelText("Display name"), "Bad Name");
|
||||
await user.click(
|
||||
canvas.getByRole("button", { name: "Create organization" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
canvas.findByText(
|
||||
"That display name is terrible. What were you thinking?",
|
||||
),
|
||||
).resolves.toBeVisible();
|
||||
},
|
||||
};
|
||||
|
||||
export const CreatesOrganization: Story = {
|
||||
beforeEach: () => {
|
||||
spyOn(API, "createOrganization").mockResolvedValue({
|
||||
...MockOrganization,
|
||||
name: "new-org",
|
||||
});
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
const body = within(canvasElement.ownerDocument.body);
|
||||
const user = userEvent.setup();
|
||||
|
||||
await user.type(canvas.getByLabelText(/slug/i), "new-org");
|
||||
await user.click(
|
||||
canvas.getByRole("button", { name: "Create organization" }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
body.findByText('Organization "new-org" created successfully.'),
|
||||
).resolves.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,17 +1,24 @@
|
||||
import { useFormik } from "formik";
|
||||
import { ArrowLeftIcon } from "lucide-react";
|
||||
import type { FC } from "react";
|
||||
import { useMutation, useQueryClient } from "react-query";
|
||||
import { Link, useNavigate } from "react-router";
|
||||
import { toast } from "sonner";
|
||||
import * as Yup from "yup";
|
||||
import { isApiValidationError } from "#/api/errors";
|
||||
import { createOrganization } from "#/api/queries/organizations";
|
||||
import type { CreateOrganizationRequest } from "#/api/typesGenerated";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Badges, PremiumBadge } from "#/components/Badges/Badges";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { FormField } from "#/components/FormField/FormField";
|
||||
import { IconField } from "#/components/IconField/IconField";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { SettingsHeaderDocsLink } from "#/components/SettingsHeader/SettingsHeader";
|
||||
import {
|
||||
SettingsHeader,
|
||||
SettingsHeaderDescription,
|
||||
SettingsHeaderDocsLink,
|
||||
SettingsHeaderTitle,
|
||||
} from "#/components/SettingsHeader/SettingsHeader";
|
||||
import { Spinner } from "#/components/Spinner/Spinner";
|
||||
import { Textarea } from "#/components/Textarea/Textarea";
|
||||
import { PremiumPaywall } from "#/modules/paywall/PremiumPaywall";
|
||||
@@ -38,15 +45,20 @@ const validationSchema = Yup.object({
|
||||
});
|
||||
|
||||
interface CreateOrganizationPageViewProps {
|
||||
error: unknown;
|
||||
onSubmit: (values: CreateOrganizationRequest) => Promise<void>;
|
||||
isEntitled: boolean;
|
||||
permissions: Permissions;
|
||||
}
|
||||
|
||||
export const CreateOrganizationPageView: FC<
|
||||
CreateOrganizationPageViewProps
|
||||
> = ({ error, onSubmit, isEntitled, permissions }) => {
|
||||
> = ({ isEntitled, permissions }) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const createOrganizationMutation = useMutation(
|
||||
createOrganization(queryClient),
|
||||
);
|
||||
const error = createOrganizationMutation.error;
|
||||
|
||||
const form = useFormik<CreateOrganizationRequest>({
|
||||
initialValues: {
|
||||
name: "",
|
||||
@@ -55,54 +67,50 @@ export const CreateOrganizationPageView: FC<
|
||||
icon: "",
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
onSubmit: (values) => {
|
||||
createOrganizationMutation.mutate(values, {
|
||||
onSuccess: () => {
|
||||
toast.success(`Organization "${values.name}" created successfully.`);
|
||||
void navigate(`/organizations/${values.name}`);
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
const navigate = useNavigate();
|
||||
const getFieldHelpers = getFormHelpers(form, error);
|
||||
const descriptionField = getFieldHelpers("description", {
|
||||
maxLength: MAX_DESCRIPTION_CHAR_LIMIT,
|
||||
helperText: "Optional. Short summary of this organization.",
|
||||
});
|
||||
const iconField = getFieldHelpers("icon", {
|
||||
helperText: "Optional. URL or emoji shown for this organization.",
|
||||
});
|
||||
const descriptionErrorId = `${descriptionField.id}-error`;
|
||||
const descriptionHelperId = `${descriptionField.id}-helper`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-row font-medium">
|
||||
<div className="absolute left-12">
|
||||
<Link
|
||||
to="/organizations"
|
||||
className="flex flex-row items-center gap-2 no-underline text-content-secondary hover:text-content-primary"
|
||||
>
|
||||
<ArrowLeftIcon size={20} />
|
||||
Go Back
|
||||
</Link>
|
||||
<section className="px-4 sm:px-6 lg:px-10 py-6 lg:py-10 grid grid-cols-1 lg:grid-cols-[1fr_minmax(0,800px)_1fr] gap-x-4 gap-y-6">
|
||||
<div>
|
||||
<Button variant="subtle" asChild className="-ml-3">
|
||||
<Link to="/organizations">
|
||||
<ArrowLeftIcon />
|
||||
<span>Back to organizations</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 w-full min-w-96 mx-auto">
|
||||
<div className="flex flex-col items-center">
|
||||
{Boolean(error) && !isApiValidationError(error) && (
|
||||
<div className="mb-8">
|
||||
<ErrorAlert error={error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isEntitled && (
|
||||
<Badges>
|
||||
<PremiumBadge />
|
||||
</Badges>
|
||||
)}
|
||||
|
||||
<header className="flex flex-col items-center">
|
||||
<h1 className="text-3xl font-semibold m-0">New Organization</h1>
|
||||
<p className="max-w-md text-sm text-content-secondary text-center">
|
||||
Organize your deployment into multiple platform teams with unique
|
||||
provisioners, templates, groups, and members.{" "}
|
||||
<div className="flex flex-col gap-4 w-full mx-auto max-w-2xl">
|
||||
<div className="flex flex-col">
|
||||
<SettingsHeader>
|
||||
<SettingsHeaderTitle>New Organization</SettingsHeaderTitle>
|
||||
<SettingsHeaderDescription>
|
||||
Isolate members, templates, and provisioners for a team or
|
||||
project.{" "}
|
||||
<SettingsHeaderDocsLink
|
||||
href={docs("/admin/users/organizations")}
|
||||
/>
|
||||
</p>
|
||||
</header>
|
||||
</div>
|
||||
{!isEntitled ? (
|
||||
<div className="mx-auto w-full max-w-4xl">
|
||||
</SettingsHeaderDescription>
|
||||
</SettingsHeader>
|
||||
|
||||
{!isEntitled ? (
|
||||
<PremiumPaywall
|
||||
source="multiple_organizations"
|
||||
message="Organizations"
|
||||
@@ -114,89 +122,103 @@ export const CreateOrganizationPageView: FC<
|
||||
]}
|
||||
canViewPremium={permissions.viewAllLicenses}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-4 w-full max-w-xl min-w-72 mx-auto">
|
||||
<form
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="flex flex-col gap-6 w-full border-none"
|
||||
) : (
|
||||
<div className="border border-solid p-6 rounded-lg">
|
||||
<form
|
||||
onSubmit={form.handleSubmit}
|
||||
aria-label="Organization settings form"
|
||||
className="flex flex-col gap-6 w-full"
|
||||
>
|
||||
<FormField
|
||||
field={getFieldHelpers("name")}
|
||||
label="Slug"
|
||||
onChange={onChangeTrimmed(form)}
|
||||
/>
|
||||
<FormField
|
||||
field={getFieldHelpers("display_name")}
|
||||
label="Display name"
|
||||
/>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={descriptionField.id}>Description</Label>
|
||||
<Textarea
|
||||
id={descriptionField.id}
|
||||
name={descriptionField.name}
|
||||
value={descriptionField.value}
|
||||
onChange={descriptionField.onChange}
|
||||
onBlur={descriptionField.onBlur}
|
||||
rows={2}
|
||||
aria-invalid={descriptionField.error}
|
||||
aria-describedby={
|
||||
descriptionField.error
|
||||
? descriptionErrorId
|
||||
: descriptionField.helperText
|
||||
? descriptionHelperId
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
descriptionField.error && "border-border-destructive",
|
||||
)}
|
||||
/>
|
||||
{descriptionField.error ? (
|
||||
<span
|
||||
id={descriptionErrorId}
|
||||
className="text-xs text-content-destructive"
|
||||
>
|
||||
{descriptionField.helperText}
|
||||
</span>
|
||||
) : (
|
||||
descriptionField.helperText && (
|
||||
{Boolean(error) && !isApiValidationError(error) && (
|
||||
<ErrorAlert error={error} />
|
||||
)}
|
||||
<fieldset
|
||||
disabled={form.isSubmitting}
|
||||
className="flex flex-col gap-6 w-full border-none p-0 m-0"
|
||||
>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 items-start gap-4">
|
||||
<FormField
|
||||
field={getFieldHelpers("name", {
|
||||
helperText: "Unique identifier used in URLs.",
|
||||
})}
|
||||
label="Slug"
|
||||
required
|
||||
className="w-full"
|
||||
onChange={onChangeTrimmed(form)}
|
||||
/>
|
||||
<FormField
|
||||
field={getFieldHelpers("display_name", {
|
||||
helperText:
|
||||
"Friendly name. Defaults to the slug if blank.",
|
||||
})}
|
||||
label="Display name"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor={descriptionField.id}>Description</Label>
|
||||
<Textarea
|
||||
id={descriptionField.id}
|
||||
name={descriptionField.name}
|
||||
value={descriptionField.value}
|
||||
onChange={descriptionField.onChange}
|
||||
onBlur={descriptionField.onBlur}
|
||||
rows={2}
|
||||
aria-invalid={descriptionField.error}
|
||||
aria-describedby={
|
||||
descriptionField.error
|
||||
? descriptionErrorId
|
||||
: descriptionField.helperText
|
||||
? descriptionHelperId
|
||||
: undefined
|
||||
}
|
||||
className={cn(
|
||||
"resize-none",
|
||||
descriptionField.error && "border-border-destructive",
|
||||
)}
|
||||
/>
|
||||
{descriptionField.error ? (
|
||||
<span
|
||||
id={descriptionHelperId}
|
||||
className="text-xs text-content-secondary"
|
||||
id={descriptionErrorId}
|
||||
className="text-xs text-content-destructive"
|
||||
>
|
||||
{descriptionField.helperText}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
) : (
|
||||
descriptionField.helperText && (
|
||||
<span
|
||||
id={descriptionHelperId}
|
||||
className="text-xs text-content-secondary"
|
||||
>
|
||||
{descriptionField.helperText}
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<IconField
|
||||
{...iconField}
|
||||
disabled={form.isSubmitting}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
onPickEmoji={(value) => {
|
||||
void form.setFieldValue("icon", value);
|
||||
void form.setFieldTouched("icon", true);
|
||||
}}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button asChild variant="outline">
|
||||
<Link to="/organizations">Cancel</Link>
|
||||
</Button>
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
<Spinner loading={form.isSubmitting} />
|
||||
Create organization
|
||||
</Button>
|
||||
</div>
|
||||
<IconField
|
||||
{...getFieldHelpers("icon")}
|
||||
onChange={onChangeTrimmed(form)}
|
||||
onPickEmoji={(value) => form.setFieldValue("icon", value)}
|
||||
/>
|
||||
</fieldset>
|
||||
<div className="flex flex-row gap-2">
|
||||
<Button type="submit" disabled={form.isSubmitting}>
|
||||
{form.isSubmitting && <Spinner />}
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => navigate("/organizations")}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user