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
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
36 changes: 36 additions & 0 deletions site/src/pages/ManagementSettingsPage/CreateOrganizationPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import type { FC } from "react";
import { useMutation, useQueryClient } from "react-query";
import { useNavigate } from "react-router-dom";
import { createOrganization } from "api/queries/organizations";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { displaySuccess } from "components/GlobalSnackbar/utils";
import { Stack } from "components/Stack/Stack";
import { CreateOrganizationPageView } from "./CreateOrganizationPageView";

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

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

const error = createOrganizationMutation.error;

return (
<Stack>
{Boolean(error) && <ErrorAlert error={error} />}

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

export default CreateOrganizationPage;
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react";
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 = {};
104 changes: 104 additions & 0 deletions site/src/pages/ManagementSettingsPage/CreateOrganizationPageView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import TextField from "@mui/material/TextField";
import { useFormik } from "formik";
import type { FC } from "react";
import * as Yup from "yup";
import type { CreateOrganizationRequest } from "api/typesGenerated";
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>

<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>>;
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createContext, type FC, Suspense, useContext } from "react";
import { useQuery } from "react-query";
import { Outlet, useParams } from "react-router-dom";
import { Outlet, useLocation, useParams } from "react-router-dom";
import { deploymentConfig } from "api/queries/deployment";
import { myOrganizations } from "api/queries/users";
import type { Organization } from "api/typesGenerated";
import { Loader } from "components/Loader/Loader";
Expand All @@ -10,10 +11,11 @@ import { useAuthenticated } from "contexts/auth/RequireAuth";
import { RequirePermission } from "contexts/auth/RequirePermission";
import { useDashboard } from "modules/dashboard/useDashboard";
import NotFoundPage from "pages/404Page/404Page";
import { DeploySettingsContext } from "../DeploySettingsPage/DeploySettingsLayout";
import { Sidebar } from "./Sidebar";

type OrganizationSettingsContextValue = {
currentOrganizationId: string;
currentOrganizationId?: string;
organizations: Organization[];
};

Expand All @@ -27,18 +29,25 @@ export const useOrganizationSettings = (): OrganizationSettingsContextValue => {
throw new Error(
"useOrganizationSettings should be used inside of OrganizationSettingsLayout",
);
return { organizations: [] };
}
return context;
};

export const OrganizationSettingsLayout: FC = () => {
export const ManagementSettingsLayout: FC = () => {
const location = useLocation();
const { permissions, organizationIds } = useAuthenticated();
const { experiments } = useDashboard();
const { organization } = useParams() as { organization: string };
const deploymentConfigQuery = useQuery(deploymentConfig());
const organizationsQuery = useQuery(myOrganizations());

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

const inOrganizationSettings =
location.pathname.startsWith("/organizations") &&
location.pathname !== "/organizations/new";

if (!multiOrgExperimentEnabled) {
return <NotFoundPage />;
}
Expand All @@ -50,18 +59,31 @@ export const OrganizationSettingsLayout: FC = () => {
{organizationsQuery.data ? (
<OrganizationSettingsContext.Provider
value={{
currentOrganizationId:
organizationsQuery.data.find(
(org) => org.name === organization,
)?.id ?? organizationIds[0],
currentOrganizationId: !inOrganizationSettings
? undefined
: !organization
? organizationIds[0]
: organizationsQuery.data.find(
(org) => org.name === organization,
)?.id,
organizations: organizationsQuery.data,
}}
>
<Sidebar />
<main css={{ width: "100%" }}>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
{deploymentConfigQuery.data ? (
<DeploySettingsContext.Provider
value={{
deploymentValues: deploymentConfigQuery.data,
}}
>
<Suspense fallback={<Loader />}>
<Outlet />
</Suspense>
</DeploySettingsContext.Provider>
) : (
<Loader />
)}
</main>
</OrganizationSettingsContext.Provider>
) : (
Expand Down
Loading
Loading