Skip to content

Commit

Permalink
[dashboard] add workspace class restriction on repo-level (#19410)
Browse files Browse the repository at this point in the history
Co-authored-by: Huiwen Huang <[email protected]>
Co-authored-by: Filip Troníček <[email protected]>
Co-authored-by: Huiwen <[email protected]>
  • Loading branch information
4 people authored Feb 29, 2024
1 parent 7decb17 commit 767c80f
Show file tree
Hide file tree
Showing 13 changed files with 554 additions and 28 deletions.
10 changes: 10 additions & 0 deletions components/dashboard/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,16 @@ export const ModalFooter: FC<ModalFooterProps> = ({ className, alert, children }
);
};

export const ModalBaseFooter: FC<{ className?: string; children: ReactNode }> = ({ className, children }) => {
return (
<div
className={classNames("flex items-start space-x-2 pt-6 bg-white dark:bg-gray-900 rounded-b-xl", className)}
>
{children}
</div>
);
};

// Wrapper around Alert to ensure it's used correctly in a Modal
export const ModalFooterAlert: FC<AlertProps> = ({
closable = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,24 +8,35 @@ import { FC, useCallback, useEffect, useMemo } from "react";
import WorkspaceClassIcon from "../icons/WorkspaceClass.svg";
import { Combobox, ComboboxElement, ComboboxSelectedItem } from "./podkit/combobox/Combobox";
import { WorkspaceClass } from "@gitpod/public-api/lib/gitpod/v1/workspace_pb";
import { useOrgWorkspaceClassesQuery } from "../data/organizations/org-workspace-classes-query";
import { useAllowedWorkspaceClassesMemo } from "../data/workspaces/workspace-classes-query";
import { PlainMessage } from "@bufbuild/protobuf";
import { Link } from "react-router-dom";
import { repositoriesRoutes } from "../repositories/repositories.routes";
import { useFeatureFlag } from "../data/featureflag-query";

interface SelectWorkspaceClassProps {
selectedConfigurationId?: string;
selectedWorkspaceClass?: string;
onSelectionChange: (workspaceClass: string) => void;
setError?: (error?: string) => void;
setError?: (error?: React.ReactNode) => void;
disabled?: boolean;
loading?: boolean;
}

export default function SelectWorkspaceClassComponent({
selectedConfigurationId,
selectedWorkspaceClass,
disabled,
loading,
setError,
onSelectionChange,
}: SelectWorkspaceClassProps) {
const { data: workspaceClasses, isLoading: workspaceClassesLoading } = useOrgWorkspaceClassesQuery();
const enabledWorkspaceClassRestrictionOnConfiguration = useFeatureFlag(
"configuration_workspace_class_restrictions",
);
const { data: workspaceClasses } = useAllowedWorkspaceClassesMemo(selectedConfigurationId, {
filterOutDisabled: true,
});

const getElements = useCallback((): ComboboxElement[] => {
return (workspaceClasses || [])?.map((c) => ({
Expand All @@ -39,17 +50,41 @@ export default function SelectWorkspaceClassComponent({
if (!workspaceClasses) {
return;
}

if (workspaceClasses.length === 0) {
const repoWorkspaceSettingsLink =
selectedConfigurationId && repositoriesRoutes.WorkspaceSettings(selectedConfigurationId);
const teamSettingsLink = "/settings";
setError?.(
"No allowed workspace classes available. Please contact an admin to update organization settings.",
<>
No allowed workspace classes available. Please contact an admin to update{" "}
<Link className="underline" to={teamSettingsLink}>
organization settings
</Link>
{enabledWorkspaceClassRestrictionOnConfiguration && repoWorkspaceSettingsLink && (
<>
{" or "}
<Link className="underline" to={repoWorkspaceSettingsLink}>
configuration settings
</Link>
</>
)}
.
</>,
);
return;
}
// if the selected workspace class is not supported, we set an error and ask the user to pick one
if (selectedWorkspaceClass && !workspaceClasses?.find((c) => c.id === selectedWorkspaceClass)) {
setError?.(`The workspace class '${selectedWorkspaceClass}' is not supported.`);
}
}, [workspaceClasses, selectedWorkspaceClass, setError]);
}, [
workspaceClasses,
selectedWorkspaceClass,
setError,
enabledWorkspaceClassRestrictionOnConfiguration,
selectedConfigurationId,
]);
const internalOnSelectionChange = useCallback(
(id: string) => {
onSelectionChange(id);
Expand All @@ -73,18 +108,15 @@ export default function SelectWorkspaceClassComponent({
searchPlaceholder="Select class"
disableSearch={true}
initialValue={selectedWsClass?.id}
disabled={workspaceClassesLoading || loading || disabled}
disabled={workspaceClasses.length === 0 || loading || disabled}
>
<WorkspaceClassDropDownElementSelected
wsClass={selectedWsClass}
loading={workspaceClassesLoading || loading}
/>
<WorkspaceClassDropDownElementSelected wsClass={selectedWsClass} loading={loading} />
</Combobox>
);
}

type WorkspaceClassDropDownElementSelectedProps = {
wsClass?: WorkspaceClass;
wsClass?: PlainMessage<WorkspaceClass>;
loading?: boolean;
};

Expand Down Expand Up @@ -112,7 +144,7 @@ const WorkspaceClassDropDownElementSelected: FC<WorkspaceClassDropDownElementSel
);
};

function WorkspaceClassDropDownElement(props: { wsClass: WorkspaceClass }): JSX.Element {
function WorkspaceClassDropDownElement(props: { wsClass: PlainMessage<WorkspaceClass> }): JSX.Element {
const c = props.wsClass;
return (
<div className="flex ml-1 mt-1 flex-grow">
Expand Down
227 changes: 227 additions & 0 deletions components/dashboard/src/components/WorkspaceClassesOptions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* Copyright (c) 2024 Gitpod GmbH. All rights reserved.
* Licensed under the GNU Affero General Public License (AGPL).
* See License.AGPL.txt in the project root for license information.
*/

import { useMemo, useState } from "react";
import { LoadingButton } from "@podkit/buttons/LoadingButton";
import { Button } from "@podkit/buttons/Button";
import { SwitchInputField } from "@podkit/switch/Switch";
import { cn } from "@podkit/lib/cn";
import { CpuIcon } from "lucide-react";
import { UseMutationResult } from "@tanstack/react-query";
import { AllowedWorkspaceClass, DEFAULT_WS_CLASS } from "../data/workspaces/workspace-classes-query";
import { MiddleDot } from "./typography/MiddleDot";
import { useToast } from "./toasts/Toasts";
import Modal, { ModalBaseFooter, ModalBody, ModalHeader } from "./Modal";

interface WorkspaceClassesOptionsProps {
classes: AllowedWorkspaceClass[];
defaultClass?: string;
className?: string;
}

export const WorkspaceClassesOptions = (props: WorkspaceClassesOptionsProps) => {
return (
<div className={cn("space-y-2", props.className)}>
{props.classes.map((cls) => (
<div className="flex gap-2 items-center">
<CpuIcon size={20} />
<div>
<span className="font-medium text-pk-content-primary">{cls.displayName}</span>
<MiddleDot />
<span className="text-pk-content-primary">{cls.description}</span>
{props.defaultClass === cls.id && (
<>
<MiddleDot className="text-pk-content-tertiary" />
<span className="text-pk-content-tertiary">default</span>
</>
)}
</div>
</div>
))}
</div>
);
};

export interface WorkspaceClassesModifyModalProps {
defaultClass?: string;
restrictedWorkspaceClasses: string[];
showSetDefaultButton: boolean;

allowedClasses: AllowedWorkspaceClass[];
updateMutation: UseMutationResult<void, Error, { restrictedWorkspaceClasses: string[]; defaultClass?: string }>;

onClose: () => void;
}

export const WorkspaceClassesModifyModal = ({
onClose,
updateMutation,
allowedClasses,
showSetDefaultButton,
...props
}: WorkspaceClassesModifyModalProps) => {
const [defaultClass, setDefaultClass] = useState(props.defaultClass || DEFAULT_WS_CLASS);
const [restrictedClasses, setRestrictedClasses] = useState(props.restrictedWorkspaceClasses ?? []);
const { toast } = useToast();

const handleUpdate = async () => {
if (computedError) {
return;
}
updateMutation.mutate(
{ restrictedWorkspaceClasses: restrictedClasses, defaultClass },
{
onSuccess: () => {
toast({ message: "Workspace class updated" });
onClose();
},
},
);
};

const computedError = useMemo(() => {
const leftOptions =
allowedClasses.filter((c) => !c.isDisabledInScope && !restrictedClasses.includes(c.id)) ?? [];
if (leftOptions.length === 0) {
return "At least one workspace class has to be selected.";
}
if (!defaultClass || !leftOptions.find((cls) => cls.id === defaultClass)) {
return "A default workspace class is required.";
}
return;
}, [restrictedClasses, allowedClasses, defaultClass]);

return (
<Modal visible onClose={onClose} onSubmit={handleUpdate}>
<ModalHeader>Available workspace classes</ModalHeader>
<ModalBody>
{allowedClasses.map((wsClass) => (
<WorkspaceClassSwitch
showSetDefaultButton={showSetDefaultButton}
restrictedClasses={restrictedClasses}
wsClass={wsClass}
isDefault={defaultClass === wsClass.id}
checked={!restrictedClasses.includes(wsClass.id)}
onSetDefault={() => {
setDefaultClass(wsClass.id);
}}
onCheckedChange={(checked) => {
const newVal = !checked
? restrictedClasses.includes(wsClass.id)
? [...restrictedClasses]
: [...restrictedClasses, wsClass.id]
: restrictedClasses.filter((id) => id !== wsClass.id);
setRestrictedClasses(newVal);
}}
/>
))}
</ModalBody>
<ModalBaseFooter className="justify-between">
<div className="text-red-500">
{(computedError || updateMutation.isError) && (computedError || String(updateMutation.error))}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<LoadingButton disabled={!!computedError} type="submit" loading={updateMutation.isLoading}>
Save
</LoadingButton>
</div>
</ModalBaseFooter>
</Modal>
);
};

interface WorkspaceClassSwitchProps {
wsClass: AllowedWorkspaceClass;
restrictedClasses: string[];
checked: boolean;
isDefault: boolean;
showSetDefaultButton?: boolean;
onSetDefault: () => void;
onCheckedChange: (checked: boolean) => void;
}
const WorkspaceClassSwitch = ({
wsClass,
checked,
isDefault,
onCheckedChange,
...props
}: WorkspaceClassSwitchProps) => {
const computedState = useMemo(() => {
if (wsClass.isDisabledInScope) {
let descriptionScope = "";
switch (wsClass.disableScope!) {
case "organization":
descriptionScope = "Your organization";
break;
case "configuration":
descriptionScope = "Current configuration";
break;
}
return {
title: "Unavailable",
classes: "cursor-not-allowed",
disabled: true,
switchDescription: `${descriptionScope} has disabled this class`,
};
}
if (props.restrictedClasses.includes(wsClass.id)) {
return {
title: "Unavailable",
classes: "cursor-not-allowed",
disabled: true,
switchDescription: "Current configuration has disabled this class",
};
}
if (isDefault) {
return {
title: "Default",
classes: "text-pk-surface",
disabled: true,
};
}
return {
title: "Set default",
classes: "cursor-pointer text-blue-500",
disabled: false,
};
}, [props.restrictedClasses, isDefault, wsClass]);

return (
<div
className={cn(
"flex w-full justify-between items-center mt-2",
wsClass.isDisabledInScope ? "text-pk-content-disabled" : "",
)}
>
<SwitchInputField
key={wsClass.id}
id={wsClass.id}
label={wsClass.displayName}
description={wsClass.description}
checked={checked}
disabled={wsClass.isDisabledInScope}
onCheckedChange={onCheckedChange}
title={computedState.switchDescription}
/>
{!props.showSetDefaultButton ? undefined : (
<Button
title={computedState.switchDescription}
onClick={() => {
props.onSetDefault();
}}
variant="ghost"
disabled={wsClass.isDisabledInScope || computedState.disabled}
className={cn("text-sm select-none font-normal", computedState.classes)}
>
{computedState.title}
</Button>
)}
</div>
);
};
9 changes: 5 additions & 4 deletions components/dashboard/src/components/podkit/switch/Switch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,20 @@ export interface SwitchInputFieldProps extends React.ComponentPropsWithoutRef<ty
}

export const SwitchInputField = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.Root>, SwitchInputFieldProps>(
({ className, checked, onCheckedChange, label, id, description, ...props }, ref) => {
({ className, checked, onCheckedChange, title, label, id, description, ...props }, ref) => {
const disabledClassName = props.disabled ? "text-pk-content-disabled" : "";
const switchProps = {
...props,
className: "",
};
return (
<div className={cn("flex gap-4", className)}>
<div className={cn("flex gap-4", className)} title={title}>
<Switch checked={checked} onCheckedChange={onCheckedChange} id={id} {...switchProps} ref={ref} />
<div className="flex flex-col">
<label className="font-semibold cursor-pointer" htmlFor={id}>
<label className={cn("font-semibold cursor-pointer", disabledClassName)} htmlFor={id}>
{label}
</label>
<TextMuted>{description}</TextMuted>
<TextMuted className={disabledClassName}>{description}</TextMuted>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ export const Heading3: FC<HeadingProps> = ({ id, tracking, className, children,
*/
export const Subheading: FC<HeadingProps> = ({ id, tracking, className, children }) => {
return (
<p id={id} className={cn("text-base text-pk-content-secondary", getTracking(tracking), className)}>
<p id={id} className={cn("text-base text-pk-content-tertiary", getTracking(tracking), className)}>
{children}
</p>
);
Expand Down
Loading

0 comments on commit 767c80f

Please sign in to comment.