feat(site): do not show popover on update deadline (#11921)

This commit is contained in:
Bruno Quaresma
2024-01-30 14:11:15 -03:00
committed by GitHub
parent dcab6fa5a4
commit e26ba1affd
3 changed files with 242 additions and 246 deletions
+11 -21
View File
@@ -1,8 +1,11 @@
import * as API from "api/api";
import { QueryClient, type QueryOptions } from "react-query";
import {
QueryClient,
UseMutationOptions,
type QueryOptions,
} from "react-query";
import { putWorkspaceExtension } from "api/api";
import dayjs from "dayjs";
import { getDeadline, getMaxDeadline, getMinDeadline } from "utils/schedule";
import { Dayjs } from "dayjs";
import {
type WorkspaceBuildParameter,
type Workspace,
@@ -103,25 +106,12 @@ export function workspaces(config: WorkspacesRequest = {}) {
} as const satisfies QueryOptions<WorkspacesResponse>;
}
export const decreaseDeadline = (workspace: Workspace) => {
export const updateDeadline = (
workspace: Workspace,
): UseMutationOptions<void, unknown, Dayjs> => {
return {
mutationFn: (hours: number) => {
const proposedDeadline = getDeadline(workspace).subtract(hours, "hours");
const newDeadline = dayjs.max(proposedDeadline, getMinDeadline());
return putWorkspaceExtension(workspace.id, newDeadline);
},
};
};
export const increaseDeadline = (workspace: Workspace) => {
return {
mutationFn: (hours: number) => {
const proposedDeadline = getDeadline(workspace).add(hours, "hours");
const newDeadline = dayjs.min(
proposedDeadline,
getMaxDeadline(workspace),
);
return putWorkspaceExtension(workspace.id, newDeadline);
mutationFn: (deadline: Dayjs) => {
return putWorkspaceExtension(workspace.id, deadline);
},
};
};
@@ -0,0 +1,156 @@
import { render, screen } from "@testing-library/react";
import { ThemeProvider } from "contexts/ThemeProvider";
import { QueryClient, QueryClientProvider, useQuery } from "react-query";
import { MockWorkspace } from "testHelpers/entities";
import { WorkspaceScheduleControls } from "./WorkspaceScheduleControls";
import { workspaceByOwnerAndName } from "api/queries/workspaces";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
import { server } from "testHelpers/server";
import { rest } from "msw";
import dayjs from "dayjs";
import * as API from "api/api";
import { GlobalSnackbar } from "components/GlobalSnackbar/GlobalSnackbar";
const Wrapper = () => {
const { data: workspace } = useQuery(
workspaceByOwnerAndName(MockWorkspace.owner_name, MockWorkspace.name),
);
if (!workspace) {
return null;
}
return <WorkspaceScheduleControls workspace={workspace} canUpdateSchedule />;
};
const BASE_DEADLINE = dayjs().add(3, "hour");
const renderScheduleControls = async () => {
server.use(
rest.get(
"/api/v2/users/:username/workspace/:workspaceName",
(req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...MockWorkspace,
latest_build: {
...MockWorkspace.latest_build,
deadline: BASE_DEADLINE.toISOString(),
},
}),
);
},
),
);
render(
<ThemeProvider>
<QueryClientProvider client={new QueryClient()}>
<RouterProvider
router={createMemoryRouter([{ path: "/", element: <Wrapper /> }])}
/>
</QueryClientProvider>
<GlobalSnackbar />
</ThemeProvider>,
);
await screen.findByTestId("schedule-controls");
expect(screen.getByText("Stop in 3 hours")).toBeInTheDocument();
};
test("add 3 hours to deadline", async () => {
const user = userEvent.setup();
const updateDeadlineSpy = jest
.spyOn(API, "putWorkspaceExtension")
.mockResolvedValue();
await renderScheduleControls();
const addButton = screen.getByRole("button", {
name: /add 1 hour to deadline/i,
});
await user.click(addButton);
await user.click(addButton);
await user.click(addButton);
await screen.findByText(
"Workspace shutdown time has been successfully updated.",
);
expect(screen.getByText("Stop in 6 hours")).toBeInTheDocument();
// Mocks are used here because the 'usedDeadline' is a dayjs object, which
// can't be directly compared.
const usedWorkspaceId = updateDeadlineSpy.mock.calls[0][0];
const usedDeadline = updateDeadlineSpy.mock.calls[0][1];
expect(usedWorkspaceId).toEqual(MockWorkspace.id);
expect(usedDeadline.toISOString()).toEqual(
BASE_DEADLINE.add(3, "hour").toISOString(),
);
});
test("remove 3 hours to deadline", async () => {
const user = userEvent.setup();
const updateDeadlineSpy = jest
.spyOn(API, "putWorkspaceExtension")
.mockResolvedValue();
await renderScheduleControls();
const subButton = screen.getByRole("button", {
name: /subtract 1 hour from deadline/i,
});
await user.click(subButton);
await user.click(subButton);
await screen.findByText(
"Workspace shutdown time has been successfully updated.",
);
expect(screen.getByText("Stop in an hour")).toBeInTheDocument();
// Mocks are used here because the 'usedDeadline' is a dayjs object, which
// can't be directly compared.
const usedWorkspaceId = updateDeadlineSpy.mock.calls[0][0];
const usedDeadline = updateDeadlineSpy.mock.calls[0][1];
expect(usedWorkspaceId).toEqual(MockWorkspace.id);
expect(usedDeadline.toISOString()).toEqual(
BASE_DEADLINE.subtract(2, "hour").toISOString(),
);
});
test("rollback to previous deadline on error", async () => {
const user = userEvent.setup();
const initialScheduleMessage = "Stop in 3 hours";
jest.spyOn(API, "putWorkspaceExtension").mockRejectedValue({});
await renderScheduleControls();
const addButton = screen.getByRole("button", {
name: /add 1 hour to deadline/i,
});
await user.click(addButton);
await user.click(addButton);
await user.click(addButton);
await screen.findByText(
"We couldn't update your workspace shutdown time. Please try again.",
);
// In case of an error, the schedule message should remain unchanged
expect(screen.getByText(initialScheduleMessage)).toBeInTheDocument();
});
test("request is only sent once when clicking multiple times", async () => {
const user = userEvent.setup();
const updateDeadlineSpy = jest
.spyOn(API, "putWorkspaceExtension")
.mockResolvedValue();
await renderScheduleControls();
const addButton = screen.getByRole("button", {
name: /add 1 hour to deadline/i,
});
await user.click(addButton);
await user.click(addButton);
await user.click(addButton);
await screen.findByText(
"Workspace shutdown time has been successfully updated.",
);
expect(updateDeadlineSpy).toHaveBeenCalledTimes(1);
});
@@ -1,7 +1,6 @@
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 { forwardRef, type FC, useRef } from "react";
import { Link as RouterLink } from "react-router-dom";
import { isWorkspaceOn } from "utils/workspace";
import type { Workspace } from "api/typesGenerated";
@@ -16,20 +15,17 @@ import {
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";
import {
updateDeadline,
workspaceByOwnerAndNameKey,
} from "api/queries/workspaces";
import { displayError, displaySuccess } from "components/GlobalSnackbar/utils";
import { useMutation, useQueryClient } from "react-query";
import { Dayjs } from "dayjs";
import { visuallyHidden } from "@mui/utils";
export interface WorkspaceScheduleControlsProps {
workspace: Workspace;
@@ -40,6 +36,7 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
workspace,
canUpdateSchedule,
}) => {
const queryClient = useQueryClient();
const deadline = getDeadline(workspace);
const maxDeadlineDecrease = getMaxDeadlineChange(deadline, getMinDeadline());
const maxDeadlineIncrease = getMaxDeadlineChange(
@@ -48,28 +45,50 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
);
const deadlinePlusEnabled = maxDeadlineIncrease >= 1;
const deadlineMinusEnabled = maxDeadlineDecrease >= 1;
const deadlineUpdateTimeout = useRef<number>();
const lastStableDeadline = useRef<Dayjs>(deadline);
const onDeadlineChangeSuccess = () => {
displaySuccess("Updated workspace shutdown time.");
};
const onDeadlineChangeFails = (error: unknown) => {
displayError(
getErrorMessage(error, "Failed to update workspace shutdown time."),
const updateWorkspaceDeadlineQueryData = (deadline: Dayjs) => {
queryClient.setQueryData(
workspaceByOwnerAndNameKey(workspace.owner_name, workspace.name),
{
...workspace,
latest_build: {
...workspace.latest_build,
deadline: deadline.toISOString(),
},
},
);
};
const decreaseMutation = useMutation({
...decreaseDeadline(workspace),
onSuccess: onDeadlineChangeSuccess,
onError: onDeadlineChangeFails,
});
const increaseMutation = useMutation({
...increaseDeadline(workspace),
onSuccess: onDeadlineChangeSuccess,
onError: onDeadlineChangeFails,
const updateDeadlineMutation = useMutation({
...updateDeadline(workspace),
onSuccess: (_, updatedDeadline) => {
displaySuccess("Workspace shutdown time has been successfully updated.");
lastStableDeadline.current = updatedDeadline;
},
onError: (error) => {
displayError(
getErrorMessage(
error,
"We couldn't update your workspace shutdown time. Please try again.",
),
);
updateWorkspaceDeadlineQueryData(lastStableDeadline.current);
},
});
const handleDeadlineChange = (newDeadline: Dayjs) => {
clearTimeout(deadlineUpdateTimeout.current);
// Optimistic update
updateWorkspaceDeadlineQueryData(newDeadline);
deadlineUpdateTimeout.current = window.setTimeout(() => {
updateDeadlineMutation.mutate(newDeadline);
}, 500);
};
return (
<div css={styles.scheduleValue}>
<div css={styles.scheduleValue} data-testid="schedule-controls">
{isWorkspaceOn(workspace) ? (
<AutoStopDisplay workspace={workspace} />
) : (
@@ -79,161 +98,39 @@ export const WorkspaceScheduleControls: FC<WorkspaceScheduleControlsProps> = ({
)}
{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"
<div css={styles.scheduleControls}>
<Tooltip title="Subtract 1 hour from deadline">
<IconButton
disabled={!deadlineMinusEnabled}
size="small"
css={styles.scheduleButton}
onClick={() => {
handleDeadlineChange(deadline.subtract(1, "h"));
}}
>
<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"
<RemoveIcon />
<span style={visuallyHidden}>Subtract 1 hour</span>
</IconButton>
</Tooltip>
<Tooltip title="Add 1 hour to deadline">
<IconButton
disabled={!deadlinePlusEnabled}
size="small"
css={styles.scheduleButton}
onClick={() => {
handleDeadlineChange(deadline.add(1, "h"));
}}
>
<AddTimeContent
maxDeadlineIncrease={maxDeadlineIncrease}
onDeadlinePlus={increaseMutation.mutate}
/>
</PopoverContent>
</Popover>
</span>
<AddIcon />
<span style={visuallyHidden}>Add 1 hour</span>
</IconButton>
</Tooltip>
</div>
)}
</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;
}
@@ -311,29 +208,12 @@ const isShutdownSoon = (workspace: Workspace): boolean => {
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 = {
scheduleValue: {
display: "flex",
alignItems: "center",
gap: 12,
fontVariantNumeric: "tabular-nums",
},
scheduleControls: {
@@ -353,34 +233,4 @@ const styles = {
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>>;