Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 8ad35c7

Browse files
authored
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)
1 parent 9df6bc7 commit 8ad35c7

File tree

8 files changed

+275
-21
lines changed

8 files changed

+275
-21
lines changed

site/src/api/api.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import axios, { AxiosRequestHeaders } from "axios"
2+
import dayjs from "dayjs"
23
import * as Types from "./types"
34
import { WorkspaceBuildTransition } from "./types"
45
import * as TypesGen from "./typesGenerated"
@@ -339,7 +340,7 @@ export const getWorkspaceBuildLogs = async (
339340

340341
export const putWorkspaceExtension = async (
341342
workspaceId: string,
342-
extendWorkspaceRequest: TypesGen.PutExtendWorkspaceRequest,
343+
newDeadline: dayjs.Dayjs,
343344
): Promise<void> => {
344-
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, extendWorkspaceRequest)
345+
await axios.put(`/api/v2/workspaces/${workspaceId}/extend`, { deadline: newDeadline })
345346
}

site/src/components/Workspace/Workspace.stories.tsx

+8
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,14 @@ Started.args = {
1717
isLoading: false,
1818
onExtend: action("extend"),
1919
},
20+
scheduleProps: {
21+
onDeadlineMinus: () => {
22+
// do nothing, this is just for storybook
23+
},
24+
onDeadlinePlus: () => {
25+
// do nothing, this is just for storybook
26+
},
27+
},
2028
workspace: Mocks.MockWorkspace,
2129
handleStart: action("start"),
2230
handleStop: action("stop"),

site/src/components/Workspace/Workspace.tsx

+10-1
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ export interface WorkspaceProps {
1919
isLoading?: boolean
2020
onExtend: () => void
2121
}
22+
scheduleProps: {
23+
onDeadlinePlus: () => void
24+
onDeadlineMinus: () => void
25+
}
2226
handleStart: () => void
2327
handleStop: () => void
2428
handleDelete: () => void
@@ -36,6 +40,7 @@ export interface WorkspaceProps {
3640
*/
3741
export const Workspace: FC<WorkspaceProps> = ({
3842
bannerProps,
43+
scheduleProps,
3944
handleStart,
4045
handleStop,
4146
handleDelete,
@@ -99,7 +104,11 @@ export const Workspace: FC<WorkspaceProps> = ({
99104
</Stack>
100105

101106
<Stack direction="column" className={styles.secondColumnSpacer} spacing={3}>
102-
<WorkspaceSchedule workspace={workspace} />
107+
<WorkspaceSchedule
108+
workspace={workspace}
109+
onDeadlineMinus={scheduleProps.onDeadlineMinus}
110+
onDeadlinePlus={scheduleProps.onDeadlinePlus}
111+
/>
103112
</Stack>
104113
</Stack>
105114
</Margins>

site/src/components/WorkspaceSchedule/WorkspaceSchedule.stories.tsx

+14
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ dayjs.extend(utc)
1111
// SEE: https:github.com/storybookjs/storybook/issues/12208#issuecomment-697044557
1212
const ONE = 1
1313
const SEVEN = 7
14+
const THIRTY = 30
1415

1516
export default {
1617
title: "components/WorkspaceSchedule",
@@ -47,6 +48,19 @@ NoTTL.args = {
4748
},
4849
}
4950

51+
export const ShutdownRealSoon = Template.bind({})
52+
ShutdownRealSoon.args = {
53+
workspace: {
54+
...Mocks.MockWorkspace,
55+
latest_build: {
56+
...Mocks.MockWorkspaceBuild,
57+
deadline: dayjs().add(THIRTY, "minute").utc().format(),
58+
transition: "start",
59+
},
60+
ttl_ms: 2 * 60 * 60 * 1000, // 2 hours
61+
},
62+
}
63+
5064
export const ShutdownSoon = Template.bind({})
5165
ShutdownSoon.args = {
5266
workspace: {
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import dayjs from "dayjs"
2+
import utc from "dayjs/plugin/utc"
3+
import * as TypesGen from "../../api/typesGenerated"
4+
import * as Mocks from "../../testHelpers/entities"
5+
import {
6+
deadlineMinusDisabled,
7+
deadlinePlusDisabled,
8+
shouldDisplayPlusMinus,
9+
} from "./WorkspaceSchedule"
10+
11+
dayjs.extend(utc)
12+
const now = dayjs()
13+
14+
describe("WorkspaceSchedule", () => {
15+
describe("shouldDisplayPlusMinus", () => {
16+
it("should not display if the workspace is not running", () => {
17+
// Given: a stopped workspace
18+
const workspace: TypesGen.Workspace = Mocks.MockStoppedWorkspace
19+
20+
// Then: shouldDisplayPlusMinus should be false
21+
expect(shouldDisplayPlusMinus(workspace)).toBeFalsy()
22+
})
23+
24+
it("should display if the workspace is running", () => {
25+
// Given: a stopped workspace
26+
const workspace: TypesGen.Workspace = Mocks.MockWorkspace
27+
28+
// Then: shouldDisplayPlusMinus should be false
29+
expect(shouldDisplayPlusMinus(workspace)).toBeTruthy()
30+
})
31+
})
32+
33+
describe("deadlineMinusDisabled", () => {
34+
it("should be false if the deadline is more than 30 minutes in the future", () => {
35+
// Given: a workspace with a deadline set to 31 minutes in the future
36+
const workspace: TypesGen.Workspace = {
37+
...Mocks.MockWorkspace,
38+
latest_build: {
39+
...Mocks.MockWorkspaceBuild,
40+
deadline: now.add(31, "minutes").utc().format(),
41+
},
42+
}
43+
44+
// Then: deadlineMinusDisabled should be falsy
45+
expect(deadlineMinusDisabled(workspace, now)).toBeFalsy()
46+
})
47+
48+
it("should be true if the deadline is 30 minutes or less in the future", () => {
49+
// Given: a workspace with a deadline set to 30 minutes in the future
50+
const workspace: TypesGen.Workspace = {
51+
...Mocks.MockWorkspace,
52+
latest_build: {
53+
...Mocks.MockWorkspaceBuild,
54+
deadline: now.add(30, "minutes").utc().format(),
55+
},
56+
}
57+
58+
// Then: deadlineMinusDisabled should be truthy
59+
expect(deadlineMinusDisabled(workspace, now)).toBeTruthy()
60+
})
61+
62+
it("should be true if the deadline is in the past", () => {
63+
// Given: a workspace with a deadline set to 1 minute in the past
64+
const workspace: TypesGen.Workspace = {
65+
...Mocks.MockWorkspace,
66+
latest_build: {
67+
...Mocks.MockWorkspaceBuild,
68+
deadline: now.add(-1, "minutes").utc().format(),
69+
},
70+
}
71+
72+
// Then: deadlineMinusDisabled should be truthy
73+
expect(deadlineMinusDisabled(workspace, now)).toBeTruthy()
74+
})
75+
})
76+
77+
describe("deadlinePlusDisabled", () => {
78+
it("should be false if the deadline is less than 24 hours in the future", () => {
79+
// Given: a workspace with a deadline set to 23 hours in the future
80+
const workspace: TypesGen.Workspace = {
81+
...Mocks.MockWorkspace,
82+
latest_build: {
83+
...Mocks.MockWorkspaceBuild,
84+
deadline: now.add(23, "hours").utc().format(),
85+
},
86+
}
87+
88+
// Then: deadlinePlusDisabled should be falsy
89+
expect(deadlinePlusDisabled(workspace, now)).toBeFalsy()
90+
})
91+
92+
it("should be true if the deadline is 24 hours or more in the future", () => {
93+
// Given: a workspace with a deadline set to 25 hours in the future
94+
const workspace: TypesGen.Workspace = {
95+
...Mocks.MockWorkspace,
96+
latest_build: {
97+
...Mocks.MockWorkspaceBuild,
98+
deadline: now.add(25, "hours").utc().format(),
99+
},
100+
}
101+
102+
// Then: deadlinePlusDisabled should be truthy
103+
expect(deadlinePlusDisabled(workspace, now)).toBeTruthy()
104+
})
105+
106+
it("should be false if the deadline is in the past", () => {
107+
// Given: a workspace with a deadline set to 1 minute in the past
108+
const workspace: TypesGen.Workspace = {
109+
...Mocks.MockWorkspace,
110+
latest_build: {
111+
...Mocks.MockWorkspaceBuild,
112+
deadline: now.add(-1, "minute").utc().format(),
113+
},
114+
}
115+
116+
// Then: deadlinePlusDisabled should be falsy
117+
expect(deadlinePlusDisabled(workspace, now)).toBeFalsy()
118+
})
119+
})
120+
})

site/src/components/WorkspaceSchedule/WorkspaceSchedule.tsx

+68-6
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
import IconButton from "@material-ui/core/IconButton"
12
import Link from "@material-ui/core/Link"
23
import { makeStyles } from "@material-ui/core/styles"
4+
import Tooltip from "@material-ui/core/Tooltip"
35
import Typography from "@material-ui/core/Typography"
6+
import AddBoxIcon from "@material-ui/icons/AddBox"
7+
import IndeterminateCheckBoxIcon from "@material-ui/icons/IndeterminateCheckBox"
48
import ScheduleIcon from "@material-ui/icons/Schedule"
59
import cronstrue from "cronstrue"
610
import dayjs from "dayjs"
@@ -66,6 +70,8 @@ export const Language = {
6670
}
6771
},
6872
editScheduleLink: "Edit schedule",
73+
editDeadlineMinus: "Subtract one hour",
74+
editDeadlinePlus: "Add one hour",
6975
scheduleHeader: (workspace: Workspace): string => {
7076
const tz = workspace.autostart_schedule
7177
? extractTimezone(workspace.autostart_schedule)
@@ -75,11 +81,61 @@ export const Language = {
7581
}
7682

7783
export interface WorkspaceScheduleProps {
84+
now?: dayjs.Dayjs
7885
workspace: Workspace
86+
onDeadlinePlus: () => void
87+
onDeadlineMinus: () => void
7988
}
8089

81-
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) => {
90+
export const shouldDisplayPlusMinus = (workspace: Workspace): boolean => {
91+
if (!isWorkspaceOn(workspace)) {
92+
return false
93+
}
94+
const deadline = dayjs(workspace.latest_build.deadline).utc()
95+
return deadline.year() > 1
96+
}
97+
98+
export const deadlineMinusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
99+
const delta = dayjs(workspace.latest_build.deadline).diff(now)
100+
return delta <= 30 * 60 * 1000 // 30 minutes
101+
}
102+
103+
export const deadlinePlusDisabled = (workspace: Workspace, now: dayjs.Dayjs): boolean => {
104+
const delta = dayjs(workspace.latest_build.deadline).diff(now)
105+
return delta >= 24 * 60 * 60 * 1000 // 24 hours
106+
}
107+
108+
export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({
109+
now,
110+
workspace,
111+
onDeadlineMinus,
112+
onDeadlinePlus,
113+
}) => {
82114
const styles = useStyles()
115+
const editDeadlineButtons = shouldDisplayPlusMinus(workspace) ? (
116+
<Stack direction="row" spacing={0}>
117+
<IconButton
118+
size="small"
119+
disabled={deadlineMinusDisabled(workspace, now ?? dayjs())}
120+
className={styles.editDeadline}
121+
onClick={onDeadlineMinus}
122+
>
123+
<Tooltip title={Language.editDeadlineMinus}>
124+
<IndeterminateCheckBoxIcon />
125+
</Tooltip>
126+
</IconButton>
127+
<IconButton
128+
size="small"
129+
disabled={deadlinePlusDisabled(workspace, now ?? dayjs())}
130+
className={styles.editDeadline}
131+
onClick={onDeadlinePlus}
132+
>
133+
<Tooltip title={Language.editDeadlinePlus}>
134+
<AddBoxIcon />
135+
</Tooltip>
136+
</IconButton>
137+
</Stack>
138+
) : null
83139

84140
return (
85141
<div className={styles.schedule}>
@@ -96,9 +152,12 @@ export const WorkspaceSchedule: FC<WorkspaceScheduleProps> = ({ workspace }) =>
96152
</div>
97153
<div>
98154
<span className={styles.scheduleLabel}>{Language.autoStopLabel}</span>
99-
<span className={[styles.scheduleValue, "chromatic-ignore"].join(" ")}>
100-
{Language.autoStopDisplay(workspace)}
101-
</span>
155+
<Stack direction="row">
156+
<span className={[styles.scheduleValue, "chromatic-ignore"].join(" ")}>
157+
{Language.autoStopDisplay(workspace)}
158+
</span>
159+
{editDeadlineButtons}
160+
</Stack>
102161
</div>
103162
<div>
104163
<Link
@@ -138,12 +197,15 @@ const useStyles = makeStyles((theme) => ({
138197
color: theme.palette.text.secondary,
139198
},
140199
scheduleValue: {
141-
fontSize: 16,
142-
marginTop: theme.spacing(0.25),
200+
fontSize: 14,
201+
marginTop: theme.spacing(0.75),
143202
display: "inline-block",
144203
color: theme.palette.text.secondary,
145204
},
146205
scheduleAction: {
147206
cursor: "pointer",
148207
},
208+
editDeadline: {
209+
color: theme.palette.text.secondary,
210+
},
149211
}))

site/src/pages/WorkspacePage/WorkspacePage.tsx

+37-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import { useMachine, useSelector } from "@xstate/react"
2+
import dayjs from "dayjs"
3+
import minMax from "dayjs/plugin/minMax"
24
import React, { useContext, useEffect } from "react"
35
import { Helmet } from "react-helmet"
46
import { useParams } from "react-router-dom"
@@ -13,6 +15,8 @@ import { XServiceContext } from "../../xServices/StateContext"
1315
import { workspaceMachine } from "../../xServices/workspace/workspaceXService"
1416
import { workspaceScheduleBannerMachine } from "../../xServices/workspaceSchedule/workspaceScheduleBannerXService"
1517

18+
dayjs.extend(minMax)
19+
1620
export const WorkspacePage: React.FC = () => {
1721
const { username: usernameQueryParam, workspace: workspaceQueryParam } = useParams()
1822
const username = firstOrItem(usernameQueryParam, null)
@@ -56,7 +60,33 @@ export const WorkspacePage: React.FC = () => {
5660
bannerProps={{
5761
isLoading: bannerState.hasTag("loading"),
5862
onExtend: () => {
59-
bannerSend({ type: "EXTEND_DEADLINE_DEFAULT", workspaceId: workspace.id })
63+
bannerSend({
64+
type: "UPDATE_DEADLINE",
65+
workspaceId: workspace.id,
66+
newDeadline: dayjs(workspace.latest_build.deadline).utc().add(4, "hours"),
67+
})
68+
},
69+
}}
70+
scheduleProps={{
71+
onDeadlineMinus: () => {
72+
bannerSend({
73+
type: "UPDATE_DEADLINE",
74+
workspaceId: workspace.id,
75+
newDeadline: boundedDeadline(
76+
dayjs(workspace.latest_build.deadline).utc().add(-1, "hours"),
77+
dayjs(),
78+
),
79+
})
80+
},
81+
onDeadlinePlus: () => {
82+
bannerSend({
83+
type: "UPDATE_DEADLINE",
84+
workspaceId: workspace.id,
85+
newDeadline: boundedDeadline(
86+
dayjs(workspace.latest_build.deadline).utc().add(1, "hours"),
87+
dayjs(),
88+
),
89+
})
6090
},
6191
}}
6292
workspace={workspace}
@@ -81,3 +111,9 @@ export const WorkspacePage: React.FC = () => {
81111
)
82112
}
83113
}
114+
115+
export const boundedDeadline = (newDeadline: dayjs.Dayjs, now: dayjs.Dayjs): dayjs.Dayjs => {
116+
const minDeadline = now.add(30, "minutes")
117+
const maxDeadline = now.add(24, "hours")
118+
return dayjs.min(dayjs.max(minDeadline, newDeadline), maxDeadline)
119+
}

0 commit comments

Comments
 (0)