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:
Jake Howell
2026-08-27 01:59:38 +07:00
committed by GitHub
parent 20ca63d1bc
commit bba0ad97d2
5 changed files with 249 additions and 167 deletions
+1 -1
View File
@@ -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();
+1 -1
View File
@@ -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>
);
};