mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): do not show popover on update deadline (#11921)
This commit is contained in:
@@ -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>>;
|
||||
|
||||
Reference in New Issue
Block a user