diff --git a/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.stories.tsx b/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.stories.tsx new file mode 100644 index 0000000000000..edafb6498e25a --- /dev/null +++ b/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.stories.tsx @@ -0,0 +1,30 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { DeleteWorkspaceDialog, DeleteWorkspaceDialogProps } from "./DeleteWorkspaceDialog" + +export default { + title: "Components/DeleteWorkspaceDialog", + component: DeleteWorkspaceDialog, + argTypes: { + onClose: { + action: "onClose", + }, + onConfirm: { + action: "onConfirm", + }, + open: { + control: "boolean", + defaultValue: true, + }, + title: { + defaultValue: "Confirm Dialog", + }, + }, +} as ComponentMeta + +const Template: Story = (args) => + +export const Example = Template.bind({}) +Example.args = { + isOpen: true, +} diff --git a/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx b/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx new file mode 100644 index 0000000000000..9e518d4be7fe7 --- /dev/null +++ b/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx @@ -0,0 +1,29 @@ +import React from "react" +import { ConfirmDialog } from "../ConfirmDialog/ConfirmDialog" + +const Language = { + deleteDialogTitle: "Delete workspace?", + deleteDialogMessage: "Deleting your workspace is irreversible. Are you sure?", +} + +export interface DeleteWorkspaceDialogProps { + isOpen: boolean + handleConfirm: () => void + handleCancel: () => void +} + +export const DeleteWorkspaceDialog: React.FC = ({ + isOpen, + handleCancel, + handleConfirm, +}) => ( + +) diff --git a/site/src/components/Workspace/Workspace.tsx b/site/src/components/Workspace/Workspace.tsx index 1194d024acb48..1cb14486ae141 100644 --- a/site/src/components/Workspace/Workspace.tsx +++ b/site/src/components/Workspace/Workspace.tsx @@ -14,6 +14,7 @@ import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats" export interface WorkspaceProps { handleStart: () => void handleStop: () => void + handleDelete: () => void handleUpdate: () => void handleCancel: () => void workspace: TypesGen.Workspace @@ -28,6 +29,7 @@ export interface WorkspaceProps { export const Workspace: React.FC = ({ handleStart, handleStop, + handleDelete, handleUpdate, handleCancel, workspace, @@ -55,6 +57,7 @@ export const Workspace: React.FC = ({ workspace={workspace} handleStart={handleStart} handleStop={handleStop} + handleDelete={handleDelete} handleUpdate={handleUpdate} handleCancel={handleCancel} /> diff --git a/site/src/components/WorkspaceActions/WorkspaceActions.tsx b/site/src/components/WorkspaceActions/WorkspaceActions.tsx index cc9f51cd19ad7..b0912084f90a8 100644 --- a/site/src/components/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/components/WorkspaceActions/WorkspaceActions.tsx @@ -2,6 +2,7 @@ import Button from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import CancelIcon from "@material-ui/icons/Cancel" import CloudDownloadIcon from "@material-ui/icons/CloudDownload" +import DeleteIcon from "@material-ui/icons/Delete" import PlayArrowRoundedIcon from "@material-ui/icons/PlayArrowRounded" import StopIcon from "@material-ui/icons/Stop" import React from "react" @@ -15,6 +16,8 @@ export const Language = { stopping: "Stopping workspace", start: "Start workspace", starting: "Starting workspace", + delete: "Delete workspace", + deleting: "Deleting workspace", cancel: "Cancel action", update: "Update workspace", } @@ -38,10 +41,14 @@ const canStart = (workspaceStatus: WorkspaceStatus) => ["stopped", "canceled", " const canStop = (workspaceStatus: WorkspaceStatus) => ["started", "canceled", "error"].includes(workspaceStatus) +const canDelete = (workspaceStatus: WorkspaceStatus) => + ["started", "stopped", "canceled", "error"].includes(workspaceStatus) + export interface WorkspaceActionsProps { workspace: Workspace handleStart: () => void handleStop: () => void + handleDelete: () => void handleUpdate: () => void handleCancel: () => void } @@ -50,6 +57,7 @@ export const WorkspaceActions: React.FC = ({ workspace, handleStart, handleStop, + handleDelete, handleUpdate, handleCancel, }) => { @@ -74,6 +82,14 @@ export const WorkspaceActions: React.FC = ({ label={Language.stop} /> )} + {canDelete(workspaceStatus) && ( + } + onClick={handleDelete} + label={Language.delete} + /> + )} {canCancelJobs(workspaceStatus) && ( { const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) await testButton(Language.stop, stopWorkspaceMock) }) + it("requests a delete job when the user presses Delete and confirms", async () => { + const deleteWorkspaceMock = jest.spyOn(api, "deleteWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) + await renderWorkspacePage() + const button = await screen.findByText(Language.delete) + await waitFor(() => fireEvent.click(button)) + const confirmDialog = await screen.findByRole("dialog") + const confirmButton = within(confirmDialog).getByText("Delete") + await waitFor(() => fireEvent.click(confirmButton)) + expect(deleteWorkspaceMock).toBeCalled() + }) it("requests a start job when the user presses Start", async () => { server.use( rest.get(`/api/v2/workspaces/${MockWorkspace.id}`, (req, res, ctx) => { diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index dcb0068d4301c..7129fd99dee7b 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,6 +1,7 @@ import { useMachine } from "@xstate/react" import React, { useEffect } from "react" -import { useParams } from "react-router-dom" +import { useNavigate, useParams } from "react-router-dom" +import { DeleteWorkspaceDialog } from "../../components/DeleteWorkspaceDialog/DeleteWorkspaceDialog" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { Margins } from "../../components/Margins/Margins" @@ -11,6 +12,7 @@ import { workspaceMachine } from "../../xServices/workspace/workspaceXService" export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() + const navigate = useNavigate() const workspaceId = firstOrItem(workspaceQueryParam, null) const [workspaceState, workspaceSend] = useMachine(workspaceMachine) @@ -32,16 +34,27 @@ export const WorkspacePage: React.FC = () => { return ( - workspaceSend("START")} - handleStop={() => workspaceSend("STOP")} - handleUpdate={() => workspaceSend("UPDATE")} - handleCancel={() => workspaceSend("CANCEL")} - resources={resources} - getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined} - builds={builds} - /> + <> + workspaceSend("START")} + handleStop={() => workspaceSend("STOP")} + handleDelete={() => workspaceSend("ASK_DELETE")} + handleUpdate={() => workspaceSend("UPDATE")} + handleCancel={() => workspaceSend("CANCEL")} + resources={resources} + getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined} + builds={builds} + /> + workspaceSend("CANCEL_DELETE")} + handleConfirm={() => { + workspaceSend("DELETE") + navigate("/workspaces") + }} + /> + ) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 075475b45a9d0..9c2beea1b3876 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -40,6 +40,9 @@ export type WorkspaceEvent = | { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | { type: "STOP" } + | { type: "ASK_DELETE" } + | { type: "DELETE" } + | { type: "CANCEL_DELETE" } | { type: "UPDATE" } | { type: "CANCEL" } | { type: "LOAD_MORE_BUILDS" } @@ -136,10 +139,17 @@ export const workspaceMachine = createMachine( on: { START: "requestingStart", STOP: "requestingStop", + ASK_DELETE: "askingDelete", UPDATE: "refreshingTemplate", CANCEL: "requestingCancel", }, }, + askingDelete: { + on: { + DELETE: "requestingDelete", + CANCEL_DELETE: "idle", + }, + }, requestingStart: { entry: "clearBuildError", invoke: { @@ -170,6 +180,21 @@ export const workspaceMachine = createMachine( }, }, }, + requestingDelete: { + entry: "clearBuildError", + invoke: { + id: "deleteWorkspace", + src: "deleteWorkspace", + onDone: { + target: "idle", + actions: ["assignBuild", "refreshTimeline"], + }, + onError: { + target: "idle", + actions: ["assignBuildError", "displayBuildError"], + }, + }, + }, requestingCancel: { entry: "clearCancellationMessage", invoke: { @@ -428,6 +453,13 @@ export const workspaceMachine = createMachine( throw Error("Cannot stop workspace without workspace id") } }, + deleteWorkspace: async (context) => { + if (context.workspace) { + return await API.deleteWorkspace(context.workspace.id) + } else { + throw Error("Cannot delete workspace without workspace id") + } + }, cancelWorkspace: async (context) => { if (context.workspace) { return await API.cancelWorkspaceBuild(context.workspace.latest_build.id)