Skip to content

Commit d977654

Browse files
authored
feat: unify organization and deployment management settings (#13602)
1 parent 9b1d8f7 commit d977654

19 files changed

+782
-254
lines changed

site/e2e/api.ts

+11
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,17 @@ export const createGroup = async (orgId: string) => {
5353
return group;
5454
};
5555

56+
export const createOrganization = async () => {
57+
const name = randomName();
58+
const org = await API.createOrganization({
59+
name,
60+
display_name: `Org ${name}`,
61+
description: `Org description ${name}`,
62+
icon: "/emojis/1f957.png",
63+
});
64+
return org;
65+
};
66+
5667
export async function verifyConfigFlagBoolean(
5768
page: Page,
5869
config: DeploymentConfig,

site/e2e/playwright.config.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -147,7 +147,7 @@ export default defineConfig({
147147
gitAuth.validatePath,
148148
),
149149
CODER_PPROF_ADDRESS: "127.0.0.1:" + coderdPProfPort,
150-
CODER_EXPERIMENTS: e2eFakeExperiment1 + "," + e2eFakeExperiment2,
150+
CODER_EXPERIMENTS: `multi-organization,${e2eFakeExperiment1},${e2eFakeExperiment2}`,
151151

152152
// Tests for Deployment / User Authentication / OIDC
153153
CODER_OIDC_ISSUER_URL: "https://accounts.google.com",

site/e2e/tests/organizations.spec.ts

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { test, expect } from "@playwright/test";
2+
import { setupApiCalls } from "../api";
3+
import { expectUrl } from "../expectUrl";
4+
import { requiresEnterpriseLicense } from "../helpers";
5+
import { beforeCoderTest } from "../hooks";
6+
7+
test.beforeEach(async ({ page }) => {
8+
await beforeCoderTest(page);
9+
await setupApiCalls(page);
10+
});
11+
12+
test("create and delete organization", async ({ page, baseURL }) => {
13+
requiresEnterpriseLicense();
14+
15+
// Create an organization
16+
await page.goto(`${baseURL}/organizations/new`, {
17+
waitUntil: "domcontentloaded",
18+
});
19+
20+
await page.getByLabel("Name", { exact: true }).fill("floop");
21+
await page.getByLabel("Display name").fill("Floop");
22+
await page.getByLabel("Description").fill("Org description floop");
23+
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png");
24+
25+
await page.getByRole("button", { name: "Submit" }).click();
26+
27+
// Expect to be redirected to the new organization
28+
await expectUrl(page).toHavePathName("/organizations/floop");
29+
await expect(page.getByText("Organization created.")).toBeVisible();
30+
31+
await page.getByRole("button", { name: "Delete this organization" }).click();
32+
const dialog = page.getByTestId("dialog");
33+
await dialog.getByLabel("Name").fill("floop");
34+
await dialog.getByRole("button", { name: "Delete" }).click();
35+
await expect(page.getByText("Organization deleted.")).toBeVisible();
36+
});

site/src/components/Alert/Alert.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ export const Alert: FC<AlertProps> = ({
5252
size="small"
5353
onClick={() => {
5454
setOpen(false);
55-
onDismiss && onDismiss();
55+
onDismiss?.();
5656
}}
5757
data-testid="dismiss-banner-btn"
5858
>

site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx

+15-1
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@ import { Margins } from "components/Margins/Margins";
88
import { Stack } from "components/Stack/Stack";
99
import { useAuthenticated } from "contexts/auth/RequireAuth";
1010
import { RequirePermission } from "contexts/auth/RequirePermission";
11+
import { useDashboard } from "modules/dashboard/useDashboard";
12+
import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout";
1113
import { Sidebar } from "./Sidebar";
1214

1315
type DeploySettingsContextValue = {
1416
deploymentValues: DeploymentConfig;
1517
};
1618

17-
const DeploySettingsContext = createContext<
19+
export const DeploySettingsContext = createContext<
1820
DeploySettingsContextValue | undefined
1921
>(undefined);
2022

@@ -29,6 +31,18 @@ export const useDeploySettings = (): DeploySettingsContextValue => {
2931
};
3032

3133
export const DeploySettingsLayout: FC = () => {
34+
const { experiments } = useDashboard();
35+
36+
const multiOrgExperimentEnabled = experiments.includes("multi-organization");
37+
38+
return multiOrgExperimentEnabled ? (
39+
<ManagementSettingsLayout />
40+
) : (
41+
<DeploySettingsLayoutInner />
42+
);
43+
};
44+
45+
const DeploySettingsLayoutInner: FC = () => {
3246
const deploymentConfigQuery = useQuery(deploymentConfig());
3347
const { permissions } = useAuthenticated();
3448

Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { FC } from "react";
2+
import { useMutation, useQueryClient } from "react-query";
3+
import { useNavigate } from "react-router-dom";
4+
import { createOrganization } from "api/queries/organizations";
5+
import { displaySuccess } from "components/GlobalSnackbar/utils";
6+
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";
7+
8+
const CreateOrganizationPage: FC = () => {
9+
const navigate = useNavigate();
10+
11+
const queryClient = useQueryClient();
12+
const createOrganizationMutation = useMutation(
13+
createOrganization(queryClient),
14+
);
15+
16+
const error = createOrganizationMutation.error;
17+
18+
return (
19+
<CreateOrganizationPageView
20+
error={error}
21+
onSubmit={async (values) => {
22+
await createOrganizationMutation.mutateAsync(values);
23+
displaySuccess("Organization created.");
24+
navigate(`/organizations/${values.name}`);
25+
}}
26+
/>
27+
);
28+
};
29+
30+
export default CreateOrganizationPage;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { Meta, StoryObj } from "@storybook/react";
2+
import { mockApiError } from "testHelpers/entities";
3+
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";
4+
5+
const meta: Meta<typeof CreateOrganizationPageView> = {
6+
title: "pages/CreateOrganizationPageView",
7+
component: CreateOrganizationPageView,
8+
};
9+
10+
export default meta;
11+
type Story = StoryObj<typeof CreateOrganizationPageView>;
12+
13+
export const Example: Story = {};
14+
15+
export const Error: Story = {
16+
args: { error: "Oh no!" },
17+
};
18+
19+
export const InvalidName: Story = {
20+
args: {
21+
error: mockApiError({
22+
message: "Display name is bad",
23+
validations: [
24+
{
25+
field: "display_name",
26+
detail: "That display name is terrible. What were you thinking?",
27+
},
28+
],
29+
}),
30+
},
31+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import TextField from "@mui/material/TextField";
2+
import { useFormik } from "formik";
3+
import type { FC } from "react";
4+
import * as Yup from "yup";
5+
import { isApiValidationError } from "api/errors";
6+
import type { CreateOrganizationRequest } from "api/typesGenerated";
7+
import { ErrorAlert } from "components/Alert/ErrorAlert";
8+
import {
9+
FormFields,
10+
FormSection,
11+
HorizontalForm,
12+
FormFooter,
13+
} from "components/Form/Form";
14+
import { IconField } from "components/IconField/IconField";
15+
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
16+
import {
17+
getFormHelpers,
18+
nameValidator,
19+
displayNameValidator,
20+
onChangeTrimmed,
21+
} from "utils/formUtils";
22+
23+
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
24+
const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`;
25+
26+
const validationSchema = Yup.object({
27+
name: nameValidator("Name"),
28+
display_name: displayNameValidator("Display name"),
29+
description: Yup.string().max(
30+
MAX_DESCRIPTION_CHAR_LIMIT,
31+
MAX_DESCRIPTION_MESSAGE,
32+
),
33+
});
34+
35+
interface CreateOrganizationPageViewProps {
36+
error: unknown;
37+
onSubmit: (values: CreateOrganizationRequest) => Promise<void>;
38+
}
39+
40+
export const CreateOrganizationPageView: FC<
41+
CreateOrganizationPageViewProps
42+
> = ({ error, onSubmit }) => {
43+
const form = useFormik<CreateOrganizationRequest>({
44+
initialValues: {
45+
name: "",
46+
display_name: "",
47+
description: "",
48+
icon: "",
49+
},
50+
validationSchema,
51+
onSubmit,
52+
});
53+
const getFieldHelpers = getFormHelpers(form, error);
54+
55+
return (
56+
<div>
57+
<PageHeader>
58+
<PageHeaderTitle>Organization settings</PageHeaderTitle>
59+
</PageHeader>
60+
61+
{Boolean(error) && !isApiValidationError(error) && (
62+
<div css={{ marginBottom: 32 }}>
63+
<ErrorAlert error={error} />
64+
</div>
65+
)}
66+
67+
<HorizontalForm
68+
onSubmit={form.handleSubmit}
69+
aria-label="Organization settings form"
70+
>
71+
<FormSection
72+
title="General info"
73+
description="Change the name or description of the organization."
74+
>
75+
<fieldset
76+
disabled={form.isSubmitting}
77+
css={{ border: "unset", padding: 0, margin: 0, width: "100%" }}
78+
>
79+
<FormFields>
80+
<TextField
81+
{...getFieldHelpers("name")}
82+
onChange={onChangeTrimmed(form)}
83+
autoFocus
84+
fullWidth
85+
label="Name"
86+
/>
87+
<TextField
88+
{...getFieldHelpers("display_name")}
89+
fullWidth
90+
label="Display name"
91+
/>
92+
<TextField
93+
{...getFieldHelpers("description")}
94+
multiline
95+
fullWidth
96+
label="Description"
97+
rows={2}
98+
/>
99+
<IconField
100+
{...getFieldHelpers("icon")}
101+
onChange={onChangeTrimmed(form)}
102+
fullWidth
103+
onPickEmoji={(value) => form.setFieldValue("icon", value)}
104+
/>
105+
</FormFields>
106+
</fieldset>
107+
</FormSection>
108+
<FormFooter isLoading={form.isSubmitting} />
109+
</HorizontalForm>
110+
</div>
111+
);
112+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
import type { Interpolation, Theme } from "@emotion/react";
2+
import type { FC, HTMLAttributes, ReactNode } from "react";
3+
4+
export const HorizontalContainer: FC<HTMLAttributes<HTMLDivElement>> = ({
5+
...attrs
6+
}) => {
7+
return <div css={styles.horizontalContainer} {...attrs} />;
8+
};
9+
10+
interface HorizontalSectionProps
11+
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
12+
title: ReactNode;
13+
description: ReactNode;
14+
children?: ReactNode;
15+
}
16+
17+
export const HorizontalSection: FC<HorizontalSectionProps> = ({
18+
children,
19+
title,
20+
description,
21+
...attrs
22+
}) => {
23+
return (
24+
<section css={styles.formSection} {...attrs}>
25+
<div css={styles.formSectionInfo}>
26+
<h2 css={styles.formSectionInfoTitle}>{title}</h2>
27+
<div css={styles.formSectionInfoDescription}>{description}</div>
28+
</div>
29+
30+
{children}
31+
</section>
32+
);
33+
};
34+
35+
const styles = {
36+
horizontalContainer: (theme) => ({
37+
display: "flex",
38+
flexDirection: "column",
39+
gap: 80,
40+
41+
[theme.breakpoints.down("md")]: {
42+
gap: 64,
43+
},
44+
}),
45+
46+
formSection: (theme) => ({
47+
display: "flex",
48+
flexDirection: "row",
49+
gap: 120,
50+
51+
[theme.breakpoints.down("lg")]: {
52+
flexDirection: "column",
53+
gap: 16,
54+
},
55+
}),
56+
57+
formSectionInfo: (theme) => ({
58+
width: "100%",
59+
flexShrink: 0,
60+
top: 24,
61+
maxWidth: 312,
62+
position: "sticky",
63+
64+
[theme.breakpoints.down("md")]: {
65+
width: "100%",
66+
position: "initial",
67+
},
68+
}),
69+
70+
formSectionInfoTitle: (theme) => ({
71+
fontSize: 20,
72+
color: theme.palette.text.primary,
73+
fontWeight: 400,
74+
margin: 0,
75+
marginBottom: 8,
76+
display: "flex",
77+
flexDirection: "row",
78+
alignItems: "center",
79+
gap: 12,
80+
}),
81+
82+
formSectionInfoDescription: (theme) => ({
83+
fontSize: 14,
84+
color: theme.palette.text.secondary,
85+
lineHeight: "160%",
86+
margin: 0,
87+
}),
88+
} satisfies Record<string, Interpolation<Theme>>;

0 commit comments

Comments
 (0)