feat: UI for canceling workspace builds (#1735)

* Start hooking up cancel

* Update xservice

* Render cancel

Changes behavior of other buttons too

* Make outdated workspace story show max buttons

* Remove retry code

* Remove loading button state

* Fix type, extend tests

* Update story
This commit is contained in:
Presley Pizzo
2022-05-25 17:58:00 -04:00
committed by GitHub
parent 35ccb88f60
commit 841d9f277c
13 changed files with 109 additions and 106 deletions
+6
View File
@@ -1,4 +1,5 @@
import axios, { AxiosRequestHeaders } from "axios"
import * as Types from "./types"
import { WorkspaceBuildTransition } from "./types"
import * as TypesGen from "./typesGenerated"
@@ -161,6 +162,11 @@ export const startWorkspace = postWorkspaceBuild("start")
export const stopWorkspace = postWorkspaceBuild("stop")
export const deleteWorkspace = postWorkspaceBuild("delete")
export const cancelWorkspaceBuild = async (workspaceBuildId: TypesGen.WorkspaceBuild["id"]): Promise<Types.Message> => {
const response = await axios.patch(`/api/v2/workspacebuilds/${workspaceBuildId}/cancel`)
return response.data
}
export const createUser = async (user: TypesGen.CreateUserRequest): Promise<TypesGen.User> => {
const response = await axios.post<TypesGen.User>("/api/v2/users", user)
return response.data
+2
View File
@@ -12,3 +12,5 @@ export interface ReconnectingPTYRequest {
}
export type WorkspaceBuildTransition = "start" | "stop" | "delete"
export type Message = { message: string }
@@ -31,7 +31,6 @@ Started.args = {
workspace: MockWorkspace,
handleStart: action("start"),
handleStop: action("stop"),
handleRetry: action("retry"),
resources: [MockWorkspaceResource, MockWorkspaceResource2],
builds: [MockWorkspaceBuild],
}
+3 -3
View File
@@ -14,8 +14,8 @@ import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
export interface WorkspaceProps {
handleStart: () => void
handleStop: () => void
handleRetry: () => void
handleUpdate: () => void
handleCancel: () => void
workspace: TypesGen.Workspace
resources?: TypesGen.WorkspaceResource[]
getResourcesError?: Error
@@ -28,8 +28,8 @@ export interface WorkspaceProps {
export const Workspace: React.FC<WorkspaceProps> = ({
handleStart,
handleStop,
handleRetry,
handleUpdate,
handleCancel,
workspace,
resources,
getResourcesError,
@@ -55,8 +55,8 @@ export const Workspace: React.FC<WorkspaceProps> = ({
workspace={workspace}
handleStart={handleStart}
handleStop={handleStop}
handleRetry={handleRetry}
handleUpdate={handleUpdate}
handleCancel={handleCancel}
/>
</div>
</div>
@@ -14,14 +14,4 @@ export const Example = Template.bind({})
Example.args = {
icon: <PlayArrowRoundedIcon />,
label: "Start workspace",
loadingLabel: "Starting workspace",
isLoading: false,
}
export const Loading = Template.bind({})
Loading.args = {
icon: <PlayArrowRoundedIcon />,
label: "Start workspace",
loadingLabel: "Starting workspace",
isLoading: true,
}
@@ -1,42 +1,17 @@
import Button from "@material-ui/core/Button"
import CircularProgress from "@material-ui/core/CircularProgress"
import { makeStyles } from "@material-ui/core/styles"
import React from "react"
export interface WorkspaceActionButtonProps {
label: string
loadingLabel: string
isLoading: boolean
icon: JSX.Element
onClick: () => void
className?: string
}
export const WorkspaceActionButton: React.FC<WorkspaceActionButtonProps> = ({
label,
loadingLabel,
isLoading,
icon,
onClick,
className,
}) => {
const styles = useStyles()
export const WorkspaceActionButton: React.FC<WorkspaceActionButtonProps> = ({ label, icon, onClick, className }) => {
return (
<Button
className={className}
startIcon={isLoading ? <CircularProgress size={12} className={styles.spinner} /> : icon}
onClick={onClick}
disabled={isLoading}
>
{isLoading ? loadingLabel : label}
<Button className={className} startIcon={icon} onClick={onClick}>
{label}
</Button>
)
}
const useStyles = makeStyles((theme) => ({
spinner: {
color: theme.palette.text.disabled,
marginRight: theme.spacing(1),
},
}))
@@ -1,9 +1,9 @@
import Button from "@material-ui/core/Button"
import Link from "@material-ui/core/Link"
import { makeStyles } from "@material-ui/core/styles"
import CancelIcon from "@material-ui/icons/Cancel"
import CloudDownloadIcon from "@material-ui/icons/CloudDownload"
import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded"
import ReplayIcon from "@material-ui/icons/Replay"
import StopIcon from "@material-ui/icons/Stop"
import React from "react"
import { Link as RouterLink } from "react-router-dom"
@@ -17,7 +17,7 @@ export const Language = {
stopping: "Stopping workspace",
start: "Start workspace",
starting: "Starting workspace",
retry: "Retry",
cancel: "Cancel action",
update: "Update workspace",
}
@@ -28,20 +28,32 @@ export const Language = {
const canAcceptJobs = (workspaceStatus: WorkspaceStatus) =>
["started", "stopped", "deleted", "error", "canceled"].includes(workspaceStatus)
/**
* Jobs that are in progress (queued or pending) can be canceled.
* @param workspaceStatus WorkspaceStatus
* @returns boolean
*/
const canCancelJobs = (workspaceStatus: WorkspaceStatus) =>
["starting", "stopping", "deleting"].includes(workspaceStatus)
const canStart = (workspaceStatus: WorkspaceStatus) => ["stopped", "canceled", "error"].includes(workspaceStatus)
const canStop = (workspaceStatus: WorkspaceStatus) => ["started", "canceled", "error"].includes(workspaceStatus)
export interface WorkspaceActionsProps {
workspace: Workspace
handleStart: () => void
handleStop: () => void
handleRetry: () => void
handleUpdate: () => void
handleCancel: () => void
}
export const WorkspaceActions: React.FC<WorkspaceActionsProps> = ({
workspace,
handleStart,
handleStop,
handleRetry,
handleUpdate,
handleCancel,
}) => {
const styles = useStyles()
const workspaceStatus = getWorkspaceStatus(workspace.latest_build)
@@ -51,30 +63,29 @@ export const WorkspaceActions: React.FC<WorkspaceActionsProps> = ({
<Link underline="none" component={RouterLink} to="edit">
<Button variant="outlined">Settings</Button>
</Link>
{(workspaceStatus === "started" || workspaceStatus === "stopping") && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<StopIcon />}
onClick={handleStop}
label={Language.stop}
loadingLabel={Language.stopping}
isLoading={workspaceStatus === "stopping"}
/>
)}
{(workspaceStatus === "stopped" || workspaceStatus === "starting") && (
{canStart(workspaceStatus) && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<PlayArrowRoundedIcon />}
onClick={handleStart}
label={Language.start}
loadingLabel={Language.starting}
isLoading={workspaceStatus === "starting"}
/>
)}
{workspaceStatus === "error" && (
<Button className={styles.actionButton} startIcon={<ReplayIcon />} onClick={handleRetry}>
{Language.retry}
</Button>
{canStop(workspaceStatus) && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<StopIcon />}
onClick={handleStop}
label={Language.stop}
/>
)}
{canCancelJobs(workspaceStatus) && (
<WorkspaceActionButton
className={styles.actionButton}
icon={<CancelIcon />}
onClick={handleCancel}
label={Language.cancel}
/>
)}
{workspace.outdated && canAcceptJobs(workspaceStatus) && (
<Button className={styles.actionButton} startIcon={<CloudDownloadIcon />} onClick={handleUpdate}>
@@ -89,6 +100,6 @@ const useStyles = makeStyles((theme) => ({
actionButton: {
// Set fixed width for the action buttons so they will not change the size
// during the transitions
width: theme.spacing(30),
width: theme.spacing(27),
},
}))
@@ -6,6 +6,7 @@ import { Workspace } from "../../api/typesGenerated"
import { Language } from "../../components/WorkspaceActions/WorkspaceActions"
import {
MockBuilds,
MockCanceledWorkspace,
MockCancelingWorkspace,
MockDeletedWorkspace,
MockDeletingWorkspace,
@@ -86,45 +87,16 @@ describe("Workspace Page", () => {
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
await testButton(Language.start, startWorkspaceMock)
})
it("requests a start job when the user presses Retry after trying to start", async () => {
// Use a workspace that failed during start
it("requests cancellation when the user presses Cancel", async () => {
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "start",
},
}),
)
return res(ctx.status(200), ctx.json(MockStartingWorkspace))
}),
)
const startWorkSpaceMock = jest.spyOn(api, "startWorkspace").mockResolvedValueOnce(MockWorkspaceBuild)
await testButton(Language.retry, startWorkSpaceMock)
})
it("requests a stop job when the user presses Retry after trying to stop", async () => {
// Use a workspace that failed during stop
server.use(
rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => {
return res(
ctx.status(200),
ctx.json({
...MockFailedWorkspace,
latest_build: {
...MockFailedWorkspace.latest_build,
transition: "stop",
},
}),
)
}),
)
const stopWorkspaceMock = jest
.spyOn(api, "stopWorkspace")
.mockImplementation(() => Promise.resolve(MockWorkspaceBuild))
await testButton(Language.retry, stopWorkspaceMock)
const cancelWorkspaceMock = jest
.spyOn(api, "cancelWorkspaceBuild")
.mockImplementation(() => Promise.resolve({ message: "job canceled" }))
await testButton(Language.cancel, cancelWorkspaceMock)
})
it("requests a template when the user presses Update", async () => {
const getTemplateMock = jest.spyOn(api, "getTemplate").mockResolvedValueOnce(MockTemplate)
@@ -153,6 +125,9 @@ describe("Workspace Page", () => {
it("shows the Canceling status when the workspace is canceling", async () => {
await testStatus(MockCancelingWorkspace, DisplayStatusLanguage.canceling)
})
it("shows the Canceled status when the workspace is canceling", async () => {
await testStatus(MockCanceledWorkspace, DisplayStatusLanguage.canceled)
})
it("shows the Deleting status when the workspace is deleting", async () => {
await testStatus(MockDeletingWorkspace, DisplayStatusLanguage.deleting)
})
@@ -36,8 +36,8 @@ export const WorkspacePage: React.FC = () => {
workspace={workspace}
handleStart={() => workspaceSend("START")}
handleStop={() => workspaceSend("STOP")}
handleRetry={() => workspaceSend("RETRY")}
handleUpdate={() => workspaceSend("UPDATE")}
handleCancel={() => workspaceSend("CANCEL")}
resources={resources}
getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined}
builds={builds}
+5 -1
View File
@@ -182,7 +182,7 @@ export const MockDeletingWorkspace: TypesGen.Workspace = {
}
export const MockDeletedWorkspace: TypesGen.Workspace = { ...MockWorkspace, latest_build: MockWorkspaceBuildDelete }
export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockWorkspace, outdated: true }
export const MockOutdatedWorkspace: TypesGen.Workspace = { ...MockFailedWorkspace, outdated: true }
export const MockWorkspaceAgent: TypesGen.WorkspaceAgent = {
architecture: "amd64",
@@ -506,3 +506,7 @@ export const MockWorkspaceBuildLogs: TypesGen.ProvisionerJobLog[] = [
output: "",
},
]
export const MockCancellationMessage = {
message: "Job successfully canceled",
}
+3
View File
@@ -130,4 +130,7 @@ export const handlers = [
rest.get("/api/v2/workspacebuilds/:workspaceBuildId/logs", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockWorkspaceBuildLogs))
}),
rest.patch("/api/v2/workspacebuilds/:workspaceBuildId/cancel", (req, res, ctx) => {
return res(ctx.status(200), ctx.json(M.MockCancellationMessage))
}),
]
+2 -2
View File
@@ -58,8 +58,8 @@ export const DisplayStatusLanguage = {
stopped: "Stopped",
deleting: "Deleting",
deleted: "Deleted",
canceling: "Canceling",
canceled: "Canceled",
canceling: "Canceling action",
canceled: "Canceled action",
failed: "Failed",
queued: "Queued",
}
@@ -1,6 +1,7 @@
import { assign, createMachine, send } from "xstate"
import { pure } from "xstate/lib/actions"
import * as API from "../../api/api"
import * as Types from "../../api/types"
import * as TypesGen from "../../api/typesGenerated"
import { displayError } from "../../components/GlobalSnackbar/utils"
@@ -32,14 +33,15 @@ export interface WorkspaceContext {
builds?: TypesGen.WorkspaceBuild[]
getBuildsError?: Error | unknown
loadMoreBuildsError?: Error | unknown
cancellationMessage: string
}
export type WorkspaceEvent =
| { type: "GET_WORKSPACE"; workspaceId: string }
| { type: "START" }
| { type: "STOP" }
| { type: "RETRY" }
| { type: "UPDATE" }
| { type: "CANCEL" }
| { type: "LOAD_MORE_BUILDS" }
| { type: "REFRESH_TIMELINE" }
@@ -62,6 +64,9 @@ export const workspaceMachine = createMachine(
stopWorkspace: {
data: TypesGen.WorkspaceBuild
}
cancelWorkspace: {
data: Types.Message
}
refreshWorkspace: {
data: TypesGen.Workspace | undefined
}
@@ -131,8 +136,8 @@ export const workspaceMachine = createMachine(
on: {
START: "requestingStart",
STOP: "requestingStop",
RETRY: [{ cond: "triedToStart", target: "requestingStart" }, { target: "requestingStop" }],
UPDATE: "refreshingTemplate",
CANCEL: "requestingCancel",
},
},
requestingStart: {
@@ -165,6 +170,21 @@ export const workspaceMachine = createMachine(
},
},
},
requestingCancel: {
entry: "clearCancellationMessage",
invoke: {
id: "cancelWorkspace",
src: "cancelWorkspace",
onDone: {
target: "idle",
actions: ["assignCancellationMessage", "refreshTimeline"],
},
onError: {
target: "idle",
actions: ["assignCancellationMessage", "displayCancellationError"],
},
},
},
refreshingTemplate: {
entry: "clearRefreshTemplateError",
invoke: {
@@ -293,6 +313,17 @@ export const workspaceMachine = createMachine(
assign({
buildError: undefined,
}),
assignCancellationMessage: (_, event) =>
assign({
cancellationMessage: event.data,
}),
clearCancellationMessage: (_) =>
assign({
cancellationMessage: undefined,
}),
displayCancellationError: (context) => {
displayError(context.cancellationMessage)
},
assignRefreshWorkspaceError: (_, event) =>
assign({
refreshWorkspaceError: event.data,
@@ -312,6 +343,7 @@ export const workspaceMachine = createMachine(
assign({
refreshTemplateError: undefined,
}),
// Resources
assignResources: assign({
resources: (_, event) => event.data,
}),
@@ -369,7 +401,6 @@ export const workspaceMachine = createMachine(
}),
},
guards: {
triedToStart: (context) => context.workspace?.latest_build.transition === "start",
hasMoreBuilds: (_) => false,
},
services: {
@@ -397,6 +428,13 @@ export const workspaceMachine = createMachine(
throw Error("Cannot stop workspace without workspace id")
}
},
cancelWorkspace: async (context) => {
if (context.workspace) {
return await API.cancelWorkspaceBuild(context.workspace.latest_build.id)
} else {
throw Error("Cannot cancel workspace without build id")
}
},
refreshWorkspace: async (context) => {
if (context.workspace) {
return await API.getWorkspace(context.workspace.id)