Skip to content

feat: unify organization and deployment management settings #13602

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 17 commits into from
Jul 1, 2024
11 changes: 11 additions & 0 deletions site/e2e/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,17 @@ export const createGroup = async (orgId: string) => {
return group;
};

export const createOrganization = async () => {
const name = randomName();
const org = await API.createOrganization({
name,
display_name: `Org ${name}`,
description: `Org description ${name}`,
icon: "/emojis/1f957.png",
});
return org;
};

export async function verifyConfigFlagBoolean(
page: Page,
config: DeploymentConfig,
Expand Down
2 changes: 1 addition & 1 deletion site/e2e/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export default defineConfig({
gitAuth.validatePath,
),
CODER_PPROF_ADDRESS: "127.0.0.1:" + coderdPProfPort,
CODER_EXPERIMENTS: e2eFakeExperiment1 + "," + e2eFakeExperiment2,
CODER_EXPERIMENTS: `multi-organization,${e2eFakeExperiment1},${e2eFakeExperiment2}`,

// Tests for Deployment / User Authentication / OIDC
CODER_OIDC_ISSUER_URL: "https://accounts.google.com",
Expand Down
39 changes: 39 additions & 0 deletions site/e2e/tests/organizations.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { test, expect } from "@playwright/test";
import {
createGroup,

Check failure on line 3 in site/e2e/tests/organizations.spec.ts

View workflow job for this annotation

GitHub Actions / fmt

'createGroup' is defined but never used. Allowed unused vars must match /^_/u
createOrganization,

Check failure on line 4 in site/e2e/tests/organizations.spec.ts

View workflow job for this annotation

GitHub Actions / fmt

'createOrganization' is defined but never used. Allowed unused vars must match /^_/u
getCurrentOrgId,

Check failure on line 5 in site/e2e/tests/organizations.spec.ts

View workflow job for this annotation

GitHub Actions / fmt

'getCurrentOrgId' is defined but never used. Allowed unused vars must match /^_/u
setupApiCalls,
} from "../api";
import { requiresEnterpriseLicense } from "../helpers";
import { beforeCoderTest } from "../hooks";
import { expectUrl } from "../expectUrl";

test.beforeEach(async ({ page }) => await beforeCoderTest(page));

test("create and delete organization", async ({ page, baseURL }) => {
requiresEnterpriseLicense();
await setupApiCalls(page);

// Create an organzation

Check warning on line 18 in site/e2e/tests/organizations.spec.ts

View workflow job for this annotation

GitHub Actions / lint

"organzation" should be "organization".
await page.goto(`${baseURL}/organizations/new`, {
waitUntil: "domcontentloaded",
});

await page.getByLabel("Name", { exact: true }).fill("floop");
await page.getByLabel("Display name").fill("Floop");
await page.getByLabel("Description").fill("Org description floop");
await page.getByLabel("Icon", { exact: true }).fill("/emojis/1f957.png");

await page.getByRole("button", { name: "Submit" }).click();

// Expect to be redirected to the new organization
await expectUrl(page).toHavePathName("/organizations/floop");
await expect(page.getByText("Organization created.")).toBeVisible();

await page.getByRole("button", { name: "Delete this organization" }).click();
const dialog = page.getByTestId("dialog");
await dialog.getByLabel("Name").fill("floop");
await dialog.getByRole("button", { name: "Delete" }).click();
await expect(page.getByText("Organization deleted.")).toBeVisible();
});
2 changes: 1 addition & 1 deletion site/src/components/Alert/Alert.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export const Alert: FC<AlertProps> = ({
size="small"
onClick={() => {
setOpen(false);
onDismiss && onDismiss();
onDismiss?.();
}}
data-testid="dismiss-banner-btn"
>
Expand Down
16 changes: 15 additions & 1 deletion site/src/pages/DeploySettingsPage/DeploySettingsLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import { Margins } from "components/Margins/Margins";
import { Stack } from "components/Stack/Stack";
import { useAuthenticated } from "contexts/auth/RequireAuth";
import { RequirePermission } from "contexts/auth/RequirePermission";
import { useDashboard } from "modules/dashboard/useDashboard";
import { ManagementSettingsLayout } from "pages/ManagementSettingsPage/ManagementSettingsLayout";
import { Sidebar } from "./Sidebar";

type DeploySettingsContextValue = {
deploymentValues: DeploymentConfig;
};

const DeploySettingsContext = createContext<
export const DeploySettingsContext = createContext<
DeploySettingsContextValue | undefined
>(undefined);

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

export const DeploySettingsLayout: FC = () => {
const { experiments } = useDashboard();

const multiOrgExperimentEnabled = experiments.includes("multi-organization");

return multiOrgExperimentEnabled ? (
<ManagementSettingsLayout />
) : (
<DeploySettingsLayoutInner />
);
};

const DeploySettingsLayoutInner: FC = () => {
const deploymentConfigQuery = useQuery(deploymentConfig());
const { permissions } = useAuthenticated();

Expand Down
30 changes: 30 additions & 0 deletions site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createOrganization } from "api/queries/organizations";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";

const CreateOrganizationPage: FC = () => {
const navigate = useNavigate();

const queryClient = useQueryClient();
const createOrganizationMutation = useMutation(
createOrganization(queryClient),
);

const error = createOrganizationMutation.error;

return (
<CreateOrganizationPageView
error={error}
onSubmit={async (values) => {
await createOrganizationMutation.mutateAsync(values);
displaySuccess("Organization created.");
navigate(`/organizations/${values.name}`);
}}
/>
);
};

export default CreateOrganizationPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Meta, StoryObj } from "@storybook/react";
import { mockApiError } from "testHelpers/entities";
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";

const meta: Meta<typeof CreateOrganizationPageView> = {
title: "pages/CreateOrganizationPageView",
component: CreateOrganizationPageView,
};

export default meta;
type Story = StoryObj<typeof CreateOrganizationPageView>;

export const Example: Story = {};

export const Error: Story = {
args: { error: "Oh no!" },
};

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?",
},
],
}),
},
};
112 changes: 112 additions & 0 deletions site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import { isApiValidationError } from "api/errors";
import type { CreateOrganizationRequest } from "api/typesGenerated";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import {
FormFields,
FormSection,
HorizontalForm,
FormFooter,
} from "components/Form/Form";
import { IconField } from "components/IconField/IconField";
import { PageHeader, PageHeaderTitle } from "components/PageHeader/PageHeader";
import {
getFormHelpers,
nameValidator,
displayNameValidator,
onChangeTrimmed,
} from "utils/formUtils";

const MAX_DESCRIPTION_CHAR_LIMIT = 128;
const MAX_DESCRIPTION_MESSAGE = `Please enter a description that is no longer than ${MAX_DESCRIPTION_CHAR_LIMIT} characters.`;

const validationSchema = Yup.object({
name: nameValidator("Name"),
display_name: displayNameValidator("Display name"),
description: Yup.string().max(
MAX_DESCRIPTION_CHAR_LIMIT,
MAX_DESCRIPTION_MESSAGE,
),
});

interface CreateOrganizationPageViewProps {
error: unknown;
onSubmit: (values: CreateOrganizationRequest) => Promise<void>;
}

export const CreateOrganizationPageView: FC<
CreateOrganizationPageViewProps
> = ({ error, onSubmit }) => {
const form = useFormik<CreateOrganizationRequest>({
initialValues: {
name: "",
display_name: "",
description: "",
icon: "",
},
validationSchema,
onSubmit,
});
const getFieldHelpers = getFormHelpers(form, error);

return (
<div>
<PageHeader>
<PageHeaderTitle>Organization settings</PageHeaderTitle>
</PageHeader>

{Boolean(error) && !isApiValidationError(error) && (
<div css={{ marginBottom: 32 }}>
<ErrorAlert error={error} />
</div>
)}

<HorizontalForm
onSubmit={form.handleSubmit}
aria-label="Organization settings form"
>
<FormSection
title="General info"
description="Change the name or description of the organization."
>
<fieldset
disabled={form.isSubmitting}
css={{ border: "unset", padding: 0, margin: 0, width: "100%" }}
>
<FormFields>
<TextField
{...getFieldHelpers("name")}
onChange={onChangeTrimmed(form)}
autoFocus
fullWidth
label="Name"
/>
<TextField
{...getFieldHelpers("display_name")}
fullWidth
label="Display name"
/>
<TextField
{...getFieldHelpers("description")}
multiline
fullWidth
label="Description"
rows={2}
/>
<IconField
{...getFieldHelpers("icon")}
onChange={onChangeTrimmed(form)}
fullWidth
onPickEmoji={(value) => form.setFieldValue("icon", value)}
/>
</FormFields>
</fieldset>
</FormSection>
<FormFooter isLoading={form.isSubmitting} />
</HorizontalForm>
</div>
);
};
88 changes: 88 additions & 0 deletions site/src/pages/ManagementSettingsPage/Horizontal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { Interpolation, Theme } from "@emotion/react";
import type { FC, HTMLAttributes, ReactNode } from "react";

export const HorizontalContainer: FC<HTMLAttributes<HTMLDivElement>> = ({
...attrs
}) => {
return <div css={styles.horizontalContainer} {...attrs} />;
};

interface HorizontalSectionProps
extends Omit<HTMLAttributes<HTMLElement>, "title"> {
title: ReactNode;
description: ReactNode;
children?: ReactNode;
}

export const HorizontalSection: FC<HorizontalSectionProps> = ({
children,
title,
description,
...attrs
}) => {
return (
<section css={styles.formSection} {...attrs}>
<div css={styles.formSectionInfo}>
<h2 css={styles.formSectionInfoTitle}>{title}</h2>
<div css={styles.formSectionInfoDescription}>{description}</div>
</div>

{children}
</section>
);
};

const styles = {
horizontalContainer: (theme) => ({
display: "flex",
flexDirection: "column",
gap: 80,

[theme.breakpoints.down("md")]: {
gap: 64,
},
}),

formSection: (theme) => ({
display: "flex",
flexDirection: "row",
gap: 120,

[theme.breakpoints.down("lg")]: {
flexDirection: "column",
gap: 16,
},
}),

formSectionInfo: (theme) => ({
width: "100%",
flexShrink: 0,
top: 24,
maxWidth: 312,
position: "sticky",

[theme.breakpoints.down("md")]: {
width: "100%",
position: "initial",
},
}),

formSectionInfoTitle: (theme) => ({
fontSize: 20,
color: theme.palette.text.primary,
fontWeight: 400,
margin: 0,
marginBottom: 8,
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: 12,
}),

formSectionInfoDescription: (theme) => ({
fontSize: 14,
color: theme.palette.text.secondary,
lineHeight: "160%",
margin: 0,
}),
} satisfies Record<string, Interpolation<Theme>>;
Loading
Loading