refactor(site): move workspace schedule controls to its own component (#11281)

This commit is contained in:
Bruno Quaresma
2023-12-20 08:46:18 -03:00
committed by GitHub
parent bfc588955c
commit e2e56d7d4f
5 changed files with 403 additions and 401 deletions
@@ -66,16 +66,6 @@ type Story = StoryObj<typeof Workspace>;
export const Running: Story = {
args: {
scheduleProps: {
onDeadlineMinus: () => {
// do nothing, this is just for storybook
},
onDeadlinePlus: () => {
// do nothing, this is just for storybook
},
maxDeadlineDecrease: 0,
maxDeadlineIncrease: 24,
},
workspace: Mocks.MockWorkspace,
handleStart: action("start"),
handleStop: action("stop"),
@@ -37,12 +37,6 @@ export type WorkspaceError =
export type WorkspaceErrors = Partial<Record<WorkspaceError, unknown>>;
export interface WorkspaceProps {
scheduleProps: {
onDeadlinePlus: (hours: number) => void;
onDeadlineMinus: (hours: number) => void;
maxDeadlineIncrease: number;
maxDeadlineDecrease: number;
};
handleStart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
handleStop: () => void;
handleRestart: (buildParameters?: TypesGen.WorkspaceBuildParameter[]) => void;
@@ -81,7 +75,6 @@ export interface WorkspaceProps {
* Workspace is the top-level component for viewing an individual workspace
*/
export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
scheduleProps,
handleStart,
handleStop,
handleRestart,
@@ -186,10 +179,6 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
workspace={workspace}
handleUpdate={handleUpdate}
canUpdateWorkspace={canUpdateWorkspace}
maxDeadlineDecrease={scheduleProps.maxDeadlineDecrease}
maxDeadlineIncrease={scheduleProps.maxDeadlineIncrease}
onDeadlineMinus={scheduleProps.onDeadlineMinus}
onDeadlinePlus={scheduleProps.onDeadlinePlus}
/>
{canUpdateWorkspace && (
@@ -3,12 +3,6 @@ import { useFeatureVisibility } from "hooks/useFeatureVisibility";
import { FC, useEffect, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useNavigate } from "react-router-dom";
import {
getDeadline,
getMaxDeadline,
getMaxDeadlineChange,
getMinDeadline,
} from "utils/schedule";
import { Workspace } from "./Workspace";
import { pageTitle } from "utils/page";
import { hasJobError } from "utils/workspace";
@@ -29,16 +23,14 @@ import { useWorkspaceBuildLogs } from "hooks/useWorkspaceBuildLogs";
import {
activate,
changeVersion,
decreaseDeadline,
deleteWorkspace,
increaseDeadline,
updateWorkspace,
stopWorkspace,
startWorkspace,
cancelBuild,
} from "api/queries/workspaces";
import { getErrorMessage } from "api/errors";
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils";
import { displayError } from "components/GlobalSnackbar/utils";
import { deploymentConfig, deploymentSSHConfig } from "api/queries/deployment";
import { WorkspacePermissions } from "./permissions";
import { workspaceResolveAutostart } from "api/queries/workspaceQuota";
@@ -101,27 +93,6 @@ export const WorkspaceReadyPage = ({
mutationFn: restartWorkspace,
});
// Schedule controls
const deadline = getDeadline(workspace);
const onDeadlineChangeSuccess = () => {
displaySuccess("Updated workspace shutdown time.");
};
const onDeadlineChangeFails = (error: unknown) => {
displayError(
getErrorMessage(error, "Failed to update workspace shutdown time."),
);
};
const decreaseMutation = useMutation({
...decreaseDeadline(workspace),
onSuccess: onDeadlineChangeSuccess,
onError: onDeadlineChangeFails,
});
const increaseMutation = useMutation({
...increaseDeadline(workspace),
onSuccess: onDeadlineChangeSuccess,
onError: onDeadlineChangeFails,
});
// Auto start
const canAutostartResponse = useQuery(
workspaceResolveAutostart(workspace.id),
@@ -227,15 +198,6 @@ export const WorkspaceReadyPage = ({
</Helmet>
<Workspace
scheduleProps={{
onDeadlineMinus: decreaseMutation.mutate,
onDeadlinePlus: increaseMutation.mutate,
maxDeadlineDecrease: getMaxDeadlineChange(deadline, getMinDeadline()),
maxDeadlineIncrease: getMaxDeadlineChange(
getMaxDeadline(workspace),
deadline,
),
}}
isUpdating={updateWorkspaceMutation.isLoading}
isRestarting={isRestarting}
workspace={workspace}
@@ -0,0 +1,389 @@
import { css } from "@emotion/css";
import { type Interpolation, type Theme } from "@emotion/react";
import Link, { LinkProps } from "@mui/material/Link";
import { forwardRef, type FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import { isWorkspaceOn } from "utils/workspace";
import type { Workspace } from "api/typesGenerated";
import {
autostartDisplay,
autostopDisplay,
getDeadline,
getMaxDeadline,
getMaxDeadlineChange,
getMinDeadline,
} from "utils/schedule";
import IconButton from "@mui/material/IconButton";
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
import AddIcon from "@mui/icons-material/AddOutlined";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import {
Popover,
PopoverContent,
PopoverTrigger,
usePopover,
} from "components/Popover/Popover";
import Tooltip from "@mui/material/Tooltip";
import _ from "lodash";
import { getErrorMessage } from "api/errors";
import { decreaseDeadline, increaseDeadline } from "api/queries/workspaces";
import { displaySuccess, displayError } from "components/GlobalSnackbar/utils";
import { useMutation } from "react-query";
export interface WorkspaceScheduleControlsProps {
workspace: Workspace;
canUpdateSchedule: boolean;
}
export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
workspace,
canUpdateSchedule,
}) => {
const deadline = getDeadline(workspace);
const maxDeadlineDecrease = getMaxDeadlineChange(deadline, getMinDeadline());
const maxDeadlineIncrease = getMaxDeadlineChange(
getMaxDeadline(workspace),
deadline,
);
const deadlinePlusEnabled = maxDeadlineIncrease >= 1;
const deadlineMinusEnabled = maxDeadlineDecrease >= 1;
const onDeadlineChangeSuccess = () => {
displaySuccess("Updated workspace shutdown time.");
};
const onDeadlineChangeFails = (error: unknown) => {
displayError(
getErrorMessage(error, "Failed to update workspace shutdown time."),
);
};
const decreaseMutation = useMutation({
...decreaseDeadline(workspace),
onSuccess: onDeadlineChangeSuccess,
onError: onDeadlineChangeFails,
});
const increaseMutation = useMutation({
...increaseDeadline(workspace),
onSuccess: onDeadlineChangeSuccess,
onError: onDeadlineChangeFails,
});
return (
<div css={styles.scheduleValue}>
{isWorkspaceOn(workspace) ? (
<AutoStopDisplay workspace={workspace} />
) : (
<ScheduleSettingsLink>
{autostartDisplay(workspace.autostart_schedule)}
</ScheduleSettingsLink>
)}
{canUpdateSchedule && canEditDeadline(workspace) && (
<span css={styles.scheduleControls}>
<Popover>
<PopoverTrigger>
<IconButton
disabled={!deadlineMinusEnabled}
size="small"
title="Subtract hours from deadline"
css={styles.scheduleButton}
>
<RemoveIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent
id="schedule-sub"
classes={{ paper: classNames.paper }}
horizontal="right"
>
<DecreaseTimeContent
maxDeadlineDecrease={maxDeadlineDecrease}
onDeadlineMinus={decreaseMutation.mutate}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger>
<IconButton
disabled={!deadlinePlusEnabled}
size="small"
title="Add hours to deadline"
css={styles.scheduleButton}
>
<AddIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent
id="schedule-add"
classes={{ paper: classNames.paper }}
horizontal="right"
>
<AddTimeContent
maxDeadlineIncrease={maxDeadlineIncrease}
onDeadlinePlus={increaseMutation.mutate}
/>
</PopoverContent>
</Popover>
</span>
)}
</div>
);
};
interface AddTimeContentProps {
maxDeadlineIncrease: number;
onDeadlinePlus: (value: number) => void;
}
const AddTimeContent: FC<AddTimeContentProps> = ({
maxDeadlineIncrease,
onDeadlinePlus,
}) => {
const popover = usePopover();
return (
<>
<h4 css={styles.timePopoverTitle}>Add hours to deadline</h4>
<p css={styles.timePopoverDescription}>
Delay the shutdown of this workspace for a few more hours. This is only
applied once.
</p>
<form
css={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
onDeadlinePlus(hours);
popover.setIsOpen(false);
}}
>
<TextField
autoFocus
name="hours"
type="number"
size="small"
fullWidth
css={styles.timePopoverField}
InputProps={{ className: classNames.deadlineFormInput }}
inputProps={{
min: 0,
max: maxDeadlineIncrease,
step: 1,
defaultValue: 1,
}}
/>
<Button css={styles.timePopoverButton} type="submit">
Apply
</Button>
</form>
</>
);
};
interface DecreaseTimeContentProps {
maxDeadlineDecrease: number;
onDeadlineMinus: (hours: number) => void;
}
export const DecreaseTimeContent: FC<DecreaseTimeContentProps> = ({
maxDeadlineDecrease,
onDeadlineMinus,
}) => {
const popover = usePopover();
return (
<>
<h4 css={styles.timePopoverTitle}>Subtract hours to deadline</h4>
<p css={styles.timePopoverDescription}>
Anticipate the shutdown of this workspace for a few more hours. This is
only applied once.
</p>
<form
css={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
onDeadlineMinus(hours);
popover.setIsOpen(false);
}}
>
<TextField
autoFocus
name="hours"
type="number"
size="small"
fullWidth
css={styles.timePopoverField}
InputProps={{ className: classNames.deadlineFormInput }}
inputProps={{
min: 0,
max: maxDeadlineDecrease,
step: 1,
defaultValue: 1,
}}
/>
<Button css={styles.timePopoverButton} type="submit">
Apply
</Button>
</form>
</>
);
};
interface AutoStopDisplayProps {
workspace: Workspace;
}
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
const display = autostopDisplay(workspace);
if (display.tooltip) {
return (
<Tooltip title={display.tooltip}>
<ScheduleSettingsLink
css={(theme) => ({
color: isShutdownSoon(workspace)
? `${theme.palette.warning.light} !important`
: undefined,
})}
>
{display.message}
</ScheduleSettingsLink>
</Tooltip>
);
}
return <ScheduleSettingsLink>{display.message}</ScheduleSettingsLink>;
};
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
(props, ref) => {
return (
<Link
ref={ref}
component={RouterLink}
to="settings/schedule"
css={{
"&:first-letter": {
textTransform: "uppercase",
},
}}
{...props}
/>
);
},
);
const hasDeadline = (workspace: Workspace): boolean => {
return Boolean(workspace.latest_build.deadline);
};
const hasAutoStart = (workspace: Workspace): boolean => {
return Boolean(workspace.autostart_schedule);
};
export const canEditDeadline = (workspace: Workspace): boolean => {
return isWorkspaceOn(workspace) && hasDeadline(workspace);
};
export const shouldDisplayScheduleControls = (
workspace: Workspace,
): boolean => {
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
return willAutoStop || willAutoStart;
};
const isShutdownSoon = (workspace: Workspace): boolean => {
const deadline = workspace.latest_build.deadline;
if (!deadline) {
return false;
}
const deadlineDate = new Date(deadline);
const now = new Date();
const diff = deadlineDate.getTime() - now.getTime();
const oneHour = 1000 * 60 * 60;
return diff < oneHour;
};
export const scheduleLabel = (workspace: Workspace) => {
return isWorkspaceOn(workspace) ? "Stops" : "Starts at";
};
const classNames = {
paper: css`
padding: 24px;
max-width: 288px;
margin-top: 8px;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 8px;
`,
deadlineFormInput: css`
font-size: 14px;
padding: 0px;
border-radius: 4px;
`,
};
const styles = {
scheduleValue: {
display: "flex",
alignItems: "center",
gap: 12,
},
scheduleControls: {
display: "flex",
alignItems: "center",
gap: 4,
},
scheduleButton: (theme) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: 4,
width: 20,
height: 20,
"& svg.MuiSvgIcon-root": {
width: 12,
height: 12,
},
}),
timePopoverTitle: {
fontWeight: 600,
margin: 0,
marginBottom: 8,
},
timePopoverDescription: (theme) => ({
color: theme.palette.text.secondary,
margin: 0,
}),
timePopoverForm: {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 0",
marginTop: 12,
},
timePopoverField: {
margin: 0,
},
timePopoverButton: {
borderRadius: 4,
paddingLeft: 16,
paddingRight: 16,
flexShrink: 0,
},
} satisfies Record<string, Interpolation<Theme>>;
+13 -341
View File
@@ -1,33 +1,21 @@
import { css } from "@emotion/css";
import { type Interpolation, type Theme } from "@emotion/react";
import Link, { LinkProps } from "@mui/material/Link";
import Link from "@mui/material/Link";
import { WorkspaceOutdatedTooltip } from "components/WorkspaceOutdatedTooltip/WorkspaceOutdatedTooltip";
import { forwardRef, type FC } from "react";
import { type FC } from "react";
import { Link as RouterLink } from "react-router-dom";
import {
getDisplayWorkspaceTemplateName,
isWorkspaceOn,
} from "utils/workspace";
import { getDisplayWorkspaceTemplateName } from "utils/workspace";
import type { Workspace } from "api/typesGenerated";
import { Stats, StatsItem } from "components/Stats/Stats";
import { autostartDisplay, autostopDisplay } from "utils/schedule";
import IconButton from "@mui/material/IconButton";
import RemoveIcon from "@mui/icons-material/RemoveOutlined";
import AddIcon from "@mui/icons-material/AddOutlined";
import TextField from "@mui/material/TextField";
import Button from "@mui/material/Button";
import { WorkspaceStatusText } from "components/WorkspaceStatusBadge/WorkspaceStatusBadge";
import { DormantDeletionStat } from "components/WorkspaceDeletion";
import {
Popover,
PopoverContent,
PopoverTrigger,
usePopover,
} from "components/Popover/Popover";
import { workspaceQuota } from "api/queries/workspaceQuota";
import { useQuery } from "react-query";
import Tooltip from "@mui/material/Tooltip";
import _ from "lodash";
import {
WorkspaceScheduleControls,
scheduleLabel,
shouldDisplayScheduleControls,
} from "./WorkspaceScheduleControls";
const Language = {
workspaceDetails: "Workspace Details",
@@ -38,26 +26,16 @@ const Language = {
export interface WorkspaceStatsProps {
workspace: Workspace;
maxDeadlineIncrease: number;
maxDeadlineDecrease: number;
canUpdateWorkspace: boolean;
onDeadlinePlus: (hours: number) => void;
onDeadlineMinus: (hours: number) => void;
handleUpdate: () => void;
}
export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
workspace,
maxDeadlineDecrease,
maxDeadlineIncrease,
canUpdateWorkspace,
handleUpdate,
onDeadlineMinus,
onDeadlinePlus,
}) => {
const displayTemplateName = getDisplayWorkspaceTemplateName(workspace);
const deadlinePlusEnabled = maxDeadlineIncrease >= 1;
const deadlineMinusEnabled = maxDeadlineDecrease >= 1;
const quotaQuery = useQuery(workspaceQuota(workspace.owner_name));
const quotaBudget = quotaQuery.data?.budget;
@@ -107,69 +85,15 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
}
/>
{shouldDisplayScheduleLabel(workspace) && (
{shouldDisplayScheduleControls(workspace) && (
<StatsItem
css={styles.statsItem}
label={scheduleLabel(workspace)}
value={
<div css={styles.scheduleValue}>
{isWorkspaceOn(workspace) ? (
<AutoStopDisplay workspace={workspace} />
) : (
<ScheduleSettingsLink>
{autostartDisplay(workspace.autostart_schedule)}
</ScheduleSettingsLink>
)}
{canUpdateWorkspace && canEditDeadline(workspace) && (
<span css={styles.scheduleControls}>
<Popover>
<PopoverTrigger>
<IconButton
disabled={!deadlineMinusEnabled}
size="small"
title="Subtract hours from deadline"
css={styles.scheduleButton}
>
<RemoveIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent
id="schedule-sub"
classes={{ paper: classNames.paper }}
horizontal="right"
>
<DecreaseTimeContent
maxDeadlineDecrease={maxDeadlineDecrease}
onDeadlineMinus={onDeadlineMinus}
/>
</PopoverContent>
</Popover>
<Popover>
<PopoverTrigger>
<IconButton
disabled={!deadlinePlusEnabled}
size="small"
title="Add hours to deadline"
css={styles.scheduleButton}
>
<AddIcon />
</IconButton>
</PopoverTrigger>
<PopoverContent
id="schedule-add"
classes={{ paper: classNames.paper }}
horizontal="right"
>
<AddTimeContent
maxDeadlineIncrease={maxDeadlineIncrease}
onDeadlinePlus={onDeadlinePlus}
/>
</PopoverContent>
</Popover>
</span>
)}
</div>
<WorkspaceScheduleControls
workspace={workspace}
canUpdateSchedule={canUpdateWorkspace}
/>
}
/>
)}
@@ -187,206 +111,6 @@ export const WorkspaceStats: FC<WorkspaceStatsProps> = ({
);
};
interface AddTimeContentProps {
maxDeadlineIncrease: number;
onDeadlinePlus: (value: number) => void;
}
const AddTimeContent: FC<AddTimeContentProps> = ({
maxDeadlineIncrease,
onDeadlinePlus,
}) => {
const popover = usePopover();
return (
<>
<span css={styles.timePopoverTitle}>Add hours to deadline</span>
<span css={styles.timePopoverDescription}>
Delay the shutdown of this workspace for a few more hours. This is only
applied once.
</span>
<form
css={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
onDeadlinePlus(hours);
popover.setIsOpen(false);
}}
>
<TextField
autoFocus
name="hours"
type="number"
size="small"
fullWidth
css={styles.timePopoverField}
InputProps={{ className: classNames.deadlineFormInput }}
inputProps={{
min: 0,
max: maxDeadlineIncrease,
step: 1,
defaultValue: 1,
}}
/>
<Button css={styles.timePopoverButton} type="submit">
Apply
</Button>
</form>
</>
);
};
interface DecreaseTimeContentProps {
maxDeadlineDecrease: number;
onDeadlineMinus: (hours: number) => void;
}
export const DecreaseTimeContent: FC<DecreaseTimeContentProps> = ({
maxDeadlineDecrease,
onDeadlineMinus,
}) => {
const popover = usePopover();
return (
<>
<span css={styles.timePopoverTitle}>Subtract hours to deadline</span>
<span css={styles.timePopoverDescription}>
Anticipate the shutdown of this workspace for a few more hours. This is
only applied once.
</span>
<form
css={styles.timePopoverForm}
onSubmit={(e) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const hours = Number(formData.get("hours"));
onDeadlineMinus(hours);
popover.setIsOpen(false);
}}
>
<TextField
autoFocus
name="hours"
type="number"
size="small"
fullWidth
css={styles.timePopoverField}
InputProps={{ className: classNames.deadlineFormInput }}
inputProps={{
min: 0,
max: maxDeadlineDecrease,
step: 1,
defaultValue: 1,
}}
/>
<Button css={styles.timePopoverButton} type="submit">
Apply
</Button>
</form>
</>
);
};
interface AutoStopDisplayProps {
workspace: Workspace;
}
const AutoStopDisplay: FC<AutoStopDisplayProps> = ({ workspace }) => {
const display = autostopDisplay(workspace);
if (display.tooltip) {
return (
<Tooltip title={display.tooltip}>
<ScheduleSettingsLink
css={(theme) => ({
color: isShutdownSoon(workspace)
? `${theme.palette.warning.light} !important`
: undefined,
})}
>
{display.message}
</ScheduleSettingsLink>
</Tooltip>
);
}
return <ScheduleSettingsLink>{display.message}</ScheduleSettingsLink>;
};
const ScheduleSettingsLink = forwardRef<HTMLAnchorElement, LinkProps>(
(props, ref) => {
return (
<Link
ref={ref}
component={RouterLink}
to="settings/schedule"
css={{
"&:first-letter": {
textTransform: "uppercase",
},
}}
{...props}
/>
);
},
);
const hasDeadline = (workspace: Workspace): boolean => {
return Boolean(workspace.latest_build.deadline);
};
const hasAutoStart = (workspace: Workspace): boolean => {
return Boolean(workspace.autostart_schedule);
};
export const canEditDeadline = (workspace: Workspace): boolean => {
return isWorkspaceOn(workspace) && hasDeadline(workspace);
};
export const shouldDisplayScheduleLabel = (workspace: Workspace): boolean => {
const willAutoStop = isWorkspaceOn(workspace) && hasDeadline(workspace);
const willAutoStart = !isWorkspaceOn(workspace) && hasAutoStart(workspace);
return willAutoStop || willAutoStart;
};
const scheduleLabel = (workspace: Workspace) => {
return isWorkspaceOn(workspace) ? "Stops" : "Starts at";
};
const isShutdownSoon = (workspace: Workspace): boolean => {
const deadline = workspace.latest_build.deadline;
if (!deadline) {
return false;
}
const deadlineDate = new Date(deadline);
const now = new Date();
const diff = deadlineDate.getTime() - now.getTime();
const oneHour = 1000 * 60 * 60;
return diff < oneHour;
};
const classNames = {
paper: css`
padding: 24px;
max-width: 288px;
margin-top: 8px;
border-radius: 4px;
display: flex;
flex-direction: column;
gap: 8px;
`,
deadlineFormInput: css`
font-size: 14px;
padding: 0px;
border-radius: 4px;
`,
};
const styles = {
stats: (theme) => ({
padding: 0,
@@ -413,56 +137,4 @@ const styles = {
fontWeight: 500,
},
},
scheduleValue: {
display: "flex",
alignItems: "center",
gap: 12,
},
scheduleControls: {
display: "flex",
alignItems: "center",
gap: 4,
},
scheduleButton: (theme) => ({
border: `1px solid ${theme.palette.divider}`,
borderRadius: 4,
width: 20,
height: 20,
"& svg.MuiSvgIcon-root": {
width: 12,
height: 12,
},
}),
timePopoverTitle: {
fontWeight: 600,
marginBottom: 8,
},
timePopoverDescription: (theme) => ({
color: theme.palette.text.secondary,
}),
timePopoverForm: {
display: "flex",
alignItems: "center",
gap: 8,
padding: "8px 0",
marginTop: 12,
},
timePopoverField: {
margin: 0,
},
timePopoverButton: {
borderRadius: 4,
paddingLeft: 16,
paddingRight: 16,
flexShrink: 0,
},
} satisfies Record<string, Interpolation<Theme>>;