mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feature: allow editing workspace deadline in UI (#2721)
This PR adds two buttons to edit the workspace deadline. - These buttons only appear when a workspace is running and has a non-zero deadline - Clicking the ➕ button increases the deadline by one hour, to a max of 24 hours in the future - Clicking the ➖ button decreases the deadline by one hour, to a minimum of 30 minutes in the future (when the warning banner appears)
This commit is contained in:
+3
-2
@@ -1,4 +1,5 @@
|
||||
import axios, { AxiosRequestHeaders } from "axios"
|
||||
import dayjs from "dayjs"
|
||||
import * as Types from "./types"
|
||||
import { WorkspaceBuildTransition } from "./types"
|
||||
import * as TypesGen from "./typesGenerated"
|
||||
@@ -339,7 +340,7 @@ export const getWorkspaceBuildLogs = async (
|
||||
|
||||
export const putWorkspaceExtension = async (
|
||||
workspaceId: string,
|
||||
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
|
||||
newDeadline: dayjs.Dayjs,
|
||||
): Promise<void> => {
|
||||
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest)
|
||||
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { deadline: newDeadline })
|
||||
}
|
||||
|
||||
@@ -17,6 +17,14 @@ Started.args = {
|
||||
isLoading: false,
|
||||
onExtend: action("extend"),
|
||||
},
|
||||
scheduleProps: {
|
||||
onDeadlineMinus: () => {
|
||||
// do nothing, this is just for storybook
|
||||
},
|
||||
onDeadlinePlus: () => {
|
||||
// do nothing, this is just for storybook
|
||||
},
|
||||
},
|
||||
workspace: Mocks.MockWorkspace,
|
||||
handleStart: action("start"),
|
||||
handleStop: action("stop"),
|
||||
|
||||
@@ -19,6 +19,10 @@ export interface WorkspaceProps {
|
||||
isLoading?: boolean
|
||||
onExtend: () => void
|
||||
}
|
||||
scheduleProps: {
|
||||
onDeadlinePlus: () => void
|
||||
onDeadlineMinus: () => void
|
||||
}
|
||||
handleStart: () => void
|
||||
handleStop: () => void
|
||||
handleDelete: () => void
|
||||
@@ -36,6 +40,7 @@ export interface WorkspaceProps {
|
||||
*/
|
||||
export const Workspace: FC<WorkspaceProps> = ({
|
||||
bannerProps,
|
||||
scheduleProps,
|
||||
handleStart,
|
||||
handleStop,
|
||||
handleDelete,
|
||||
@@ -99,7 +104,11 @@ export const Workspace: FC<WorkspaceProps> = ({
|
||||
</Stack>
|
||||
|
||||
<Stack direction="column" className={styles.secondColumnSpacer} spacing={3}>
|
||||
<WorkspaceSchedule workspace={workspace} />
|
||||
<WorkspaceSchedule
|
||||
workspace={workspace}
|
||||
onDeadlineMinus={scheduleProps.onDeadlineMinus}
|
||||
onDeadlinePlus={scheduleProps.onDeadlinePlus}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Margins>
|
||||
|
||||
@@ -11,6 +11,7 @@ dayjs.extend(utc)
|
||||
// SEE: https:github.com/storybookjs/storybook/issues/12208#issuecomment-697044557
|
||||
const ONE = 1
|
||||
const SEVEN = 7
|
||||
const THIRTY = 30
|
||||
|
||||
export default {
|
||||
title: "components/WorkspaceSchedule",
|
||||
@@ -47,6 +48,19 @@ NoTTL.args = {
|
||||
},
|
||||
}
|
||||
|
||||
export const ShutdownRealSoon = Template.bind({})
|
||||
ShutdownRealSoon.args = {
|
||||
workspace: {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: dayjs().add(THIRTY, "minute").utc().format(),
|
||||
transition: "start",
|
||||
},
|
||||
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
|
||||
},
|
||||
}
|
||||
|
||||
export const ShutdownSoon = Template.bind({})
|
||||
ShutdownSoon.args = {
|
||||
workspace: {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import dayjs from "dayjs"
|
||||
import utc from "dayjs/plugin/utc"
|
||||
import * as TypesGen from "../../api/typesGenerated"
|
||||
import * as Mocks from "../../testHelpers/entities"
|
||||
import {
|
||||
deadlineMinusDisabled,
|
||||
deadlinePlusDisabled,
|
||||
shouldDisplayPlusMinus,
|
||||
} from "./WorkspaceSchedule"
|
||||
|
||||
dayjs.extend(utc)
|
||||
const now = dayjs()
|
||||
|
||||
describe("WorkspaceSchedule", () => {
|
||||
describe("shouldDisplayPlusMinus", () => {
|
||||
it("should not display if the workspace is not running", () => {
|
||||
// Given: a stopped workspace
|
||||
const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace
|
||||
|
||||
// Then: shouldDisplayPlusMinus should be false
|
||||
expect(shouldDisplayPlusMinus(workspace)).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should display if the workspace is running", () => {
|
||||
// Given: a stopped workspace
|
||||
const workspace: TypesGen.Workspace = Mocks.MockWorkspace
|
||||
|
||||
// Then: shouldDisplayPlusMinus should be false
|
||||
expect(shouldDisplayPlusMinus(workspace)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("deadlineMinusDisabled", () => {
|
||||
it("should be false if the deadline is more than 30 minutes in the future", () => {
|
||||
// Given: a workspace with a deadline set to 31 minutes in the future
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: now.add(31, "minutes").utc().format(),
|
||||
},
|
||||
}
|
||||
|
||||
// Then: deadlineMinusDisabled should be falsy
|
||||
expect(deadlineMinusDisabled(workspace, now)).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should be true if the deadline is 30 minutes or less in the future", () => {
|
||||
// Given: a workspace with a deadline set to 30 minutes in the future
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: now.add(30, "minutes").utc().format(),
|
||||
},
|
||||
}
|
||||
|
||||
// Then: deadlineMinusDisabled should be truthy
|
||||
expect(deadlineMinusDisabled(workspace, now)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should be true if the deadline is in the past", () => {
|
||||
// Given: a workspace with a deadline set to 1 minute in the past
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: now.add(-1, "minutes").utc().format(),
|
||||
},
|
||||
}
|
||||
|
||||
// Then: deadlineMinusDisabled should be truthy
|
||||
expect(deadlineMinusDisabled(workspace, now)).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe("deadlinePlusDisabled", () => {
|
||||
it("should be false if the deadline is less than 24 hours in the future", () => {
|
||||
// Given: a workspace with a deadline set to 23 hours in the future
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: now.add(23, "hours").utc().format(),
|
||||
},
|
||||
}
|
||||
|
||||
// Then: deadlinePlusDisabled should be falsy
|
||||
expect(deadlinePlusDisabled(workspace, now)).toBeFalsy()
|
||||
})
|
||||
|
||||
it("should be true if the deadline is 24 hours or more in the future", () => {
|
||||
// Given: a workspace with a deadline set to 25 hours in the future
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: now.add(25, "hours").utc().format(),
|
||||
},
|
||||
}
|
||||
|
||||
// Then: deadlinePlusDisabled should be truthy
|
||||
expect(deadlinePlusDisabled(workspace, now)).toBeTruthy()
|
||||
})
|
||||
|
||||
it("should be false if the deadline is in the past", () => {
|
||||
// Given: a workspace with a deadline set to 1 minute in the past
|
||||
const workspace: TypesGen.Workspace = {
|
||||
...Mocks.MockWorkspace,
|
||||
latest_build: {
|
||||
...Mocks.MockWorkspaceBuild,
|
||||
deadline: now.add(-1, "minute").utc().format(),
|
||||
},
|
||||
}
|
||||
|
||||
// Then: deadlinePlusDisabled should be falsy
|
||||
expect(deadlinePlusDisabled(workspace, now)).toBeFalsy()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import IconButton from "@material-ui/core/IconButton"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { makeStyles } from "@material-ui/core/styles"
|
||||
import Tooltip from "@material-ui/core/Tooltip"
|
||||
import Typography from "@material-ui/core/Typography"
|
||||
import AddBoxIcon from "@material-ui/icons/AddBox"
|
||||
import IndeterminateCheckBoxIcon from "@material-ui/icons/IndeterminateCheckBox"
|
||||
import ScheduleIcon from "@material-ui/icons/Schedule"
|
||||
import cronstrue from "cronstrue"
|
||||
import dayjs from "dayjs"
|
||||
@@ -66,6 +70,8 @@ export const Language = {
|
||||
}
|
||||
},
|
||||
editScheduleLink: "Edit schedule",
|
||||
editDeadlineMinus: "Subtract one hour",
|
||||
editDeadlinePlus: "Add one hour",
|
||||
scheduleHeader: (workspace: Workspace): string => {
|
||||
const tz = workspace.autostart_schedule
|
||||
? extractTimezone(workspace.autostart_schedule)
|
||||
@@ -75,11 +81,61 @@ export const Language = {
|
||||
}
|
||||
|
||||
export interface WorkspaceScheduleProps {
|
||||
now?: dayjs.Dayjs
|
||||
workspace: Workspace
|
||||
onDeadlinePlus: () => void
|
||||
onDeadlineMinus: () => void
|
||||
}
|
||||
|
||||
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) => {
|
||||
export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
|
||||
if (!isWorkspaceOn(workspace)) {
|
||||
return false
|
||||
}
|
||||
const deadline = dayjs(workspace.latest_build.deadline).utc()
|
||||
return deadline.year() > 1
|
||||
}
|
||||
|
||||
export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
|
||||
const delta = dayjs(workspace.latest_build.deadline).diff(now)
|
||||
return delta <= 30 * 60 * 1000 // 30 minutes
|
||||
}
|
||||
|
||||
export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
|
||||
const delta = dayjs(workspace.latest_build.deadline).diff(now)
|
||||
return delta >= 24 * 60 * 60 * 1000 // 24 hours
|
||||
}
|
||||
|
||||
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
|
||||
now,
|
||||
workspace,
|
||||
onDeadlineMinus,
|
||||
onDeadlinePlus,
|
||||
}) => {
|
||||
const styles = useStyles()
|
||||
const editDeadlineButtons = shouldDisplayPlusMinus(workspace) ? (
|
||||
<Stack direction="row" spacing={0}>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={deadlineMinusDisabled(workspace, now ?? dayjs())}
|
||||
className={styles.editDeadline}
|
||||
onClick={onDeadlineMinus}
|
||||
>
|
||||
<Tooltip title={Language.editDeadlineMinus}>
|
||||
<IndeterminateCheckBoxIcon />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
disabled={deadlinePlusDisabled(workspace, now ?? dayjs())}
|
||||
className={styles.editDeadline}
|
||||
onClick={onDeadlinePlus}
|
||||
>
|
||||
<Tooltip title={Language.editDeadlinePlus}>
|
||||
<AddBoxIcon />
|
||||
</Tooltip>
|
||||
</IconButton>
|
||||
</Stack>
|
||||
) : null
|
||||
|
||||
return (
|
||||
<div className={styles.schedule}>
|
||||
@@ -96,9 +152,12 @@ export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) =>
|
||||
</div>
|
||||
<div>
|
||||
<span className={styles.scheduleLabel}>{Language.autoStopLabel}</span>
|
||||
<span className={[styles.scheduleValue, "chromatic-ignore"].join(" ")}>
|
||||
{Language.autoStopDisplay(workspace)}
|
||||
</span>
|
||||
<Stack direction="row">
|
||||
<span className={[styles.scheduleValue, "chromatic-ignore"].join(" ")}>
|
||||
{Language.autoStopDisplay(workspace)}
|
||||
</span>
|
||||
{editDeadlineButtons}
|
||||
</Stack>
|
||||
</div>
|
||||
<div>
|
||||
<Link
|
||||
@@ -138,12 +197,15 @@ const useStyles = makeStyles((theme) => ({
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
scheduleValue: {
|
||||
fontSize: 16,
|
||||
marginTop: theme.spacing(0.25),
|
||||
fontSize: 14,
|
||||
marginTop: theme.spacing(0.75),
|
||||
display: "inline-block",
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
scheduleAction: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
editDeadline: {
|
||||
color: theme.palette.text.secondary,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMachine, useSelector } from "@xstate/react"
|
||||
import dayjs from "dayjs"
|
||||
import minMax from "dayjs/plugin/minMax"
|
||||
import React, { useContext, useEffect } from "react"
|
||||
import { Helmet } from "react-helmet"
|
||||
import { useParams } from "react-router-dom"
|
||||
@@ -13,6 +15,8 @@ import { XServiceContext } from "../../xServices/StateContext"
|
||||
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
|
||||
import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
|
||||
|
||||
dayjs.extend(minMax)
|
||||
|
||||
export const WorkspacePage: React.FC = () => {
|
||||
const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams()
|
||||
const username = firstOrItem(usernameQueryParam, null)
|
||||
@@ -56,7 +60,33 @@ export const WorkspacePage: React.FC = () => {
|
||||
bannerProps={{
|
||||
isLoading: bannerState.hasTag("loading"),
|
||||
onExtend: () => {
|
||||
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
|
||||
bannerSend({
|
||||
type: "UPDATE_DEADLINE",
|
||||
workspaceId: workspace.id,
|
||||
newDeadline: dayjs(workspace.latest_build.deadline).utc().add(4, "hours"),
|
||||
})
|
||||
},
|
||||
}}
|
||||
scheduleProps={{
|
||||
onDeadlineMinus: () => {
|
||||
bannerSend({
|
||||
type: "UPDATE_DEADLINE",
|
||||
workspaceId: workspace.id,
|
||||
newDeadline: boundedDeadline(
|
||||
dayjs(workspace.latest_build.deadline).utc().add(-1, "hours"),
|
||||
dayjs(),
|
||||
),
|
||||
})
|
||||
},
|
||||
onDeadlinePlus: () => {
|
||||
bannerSend({
|
||||
type: "UPDATE_DEADLINE",
|
||||
workspaceId: workspace.id,
|
||||
newDeadline: boundedDeadline(
|
||||
dayjs(workspace.latest_build.deadline).utc().add(1, "hours"),
|
||||
dayjs(),
|
||||
),
|
||||
})
|
||||
},
|
||||
}}
|
||||
workspace={workspace}
|
||||
@@ -81,3 +111,9 @@ export const WorkspacePage: React.FC = () => {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const boundedDeadline = (newDeadline: dayjs.Dayjs, now: dayjs.Dayjs): dayjs.Dayjs => {
|
||||
const minDeadline = now.add(30, "minutes")
|
||||
const maxDeadline = now.add(24, "hours")
|
||||
return dayjs.min(dayjs.max(minDeadline, newDeadline), maxDeadline)
|
||||
}
|
||||
|
||||
@@ -1,18 +1,22 @@
|
||||
/**
|
||||
* @fileoverview workspaceScheduleBanner is an xstate machine backing a form,
|
||||
* presented as an Alert/banner, for reactively extending a workspace schedule.
|
||||
* presented as an Alert/banner, for reactively updating a workspace schedule.
|
||||
*/
|
||||
import dayjs from "dayjs"
|
||||
import { createMachine } from "xstate"
|
||||
import * as API from "../../api/api"
|
||||
import { displayError, displaySuccess } from "../../components/GlobalSnackbar/utils"
|
||||
import { defaultWorkspaceExtension } from "../../util/workspace"
|
||||
|
||||
export const Language = {
|
||||
errorExtension: "Failed to extend workspace deadline.",
|
||||
successExtension: "Successfully extended workspace deadline.",
|
||||
errorExtension: "Failed to update workspace shutdown time.",
|
||||
successExtension: "Updated workspace shutdown time.",
|
||||
}
|
||||
|
||||
export type WorkspaceScheduleBannerEvent = { type: "EXTEND_DEADLINE_DEFAULT"; workspaceId: string }
|
||||
export type WorkspaceScheduleBannerEvent = {
|
||||
type: "UPDATE_DEADLINE"
|
||||
workspaceId: string
|
||||
newDeadline: dayjs.Dayjs
|
||||
}
|
||||
|
||||
export const workspaceScheduleBannerMachine = createMachine(
|
||||
{
|
||||
@@ -25,13 +29,13 @@ export const workspaceScheduleBannerMachine = createMachine(
|
||||
states: {
|
||||
idle: {
|
||||
on: {
|
||||
EXTEND_DEADLINE_DEFAULT: "extendingDeadline",
|
||||
UPDATE_DEADLINE: "updatingDeadline",
|
||||
},
|
||||
},
|
||||
extendingDeadline: {
|
||||
updatingDeadline: {
|
||||
invoke: {
|
||||
src: "extendDeadlineDefault",
|
||||
id: "extendDeadlineDefault",
|
||||
src: "updateDeadline",
|
||||
id: "updateDeadline",
|
||||
onDone: {
|
||||
target: "idle",
|
||||
actions: "displaySuccessMessage",
|
||||
@@ -56,8 +60,8 @@ export const workspaceScheduleBannerMachine = createMachine(
|
||||
},
|
||||
|
||||
services: {
|
||||
extendDeadlineDefault: async (_, event) => {
|
||||
await API.putWorkspaceExtension(event.workspaceId, defaultWorkspaceExtension())
|
||||
updateDeadline: async (_, event) => {
|
||||
await API.putWorkspaceExtension(event.workspaceId, event.newDeadline)
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user