From 12a5b4741a7ffa53ea64e4fcd8039cbcf5e3fff6 Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 26 May 2022 21:12:39 +0000 Subject: [PATCH 1/5] Add delete button --- site/src/components/Workspace/Workspace.tsx | 3 +++ .../WorkspaceActions/WorkspaceActions.tsx | 16 +++++++++++++ .../WorkspacePage/WorkspacePage.test.tsx | 4 ++++ .../src/pages/WorkspacePage/WorkspacePage.tsx | 1 + .../xServices/workspace/workspaceXService.ts | 24 +++++++++++++++++++ 5 files changed, 48 insertions(+) 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", async () => { + const deleteWorkspaceMock = jest.spyOn(api, "deleteWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) + await testButton(Language.delete, deleteWorkspaceMock) + }) 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..6231bb916e125 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -36,6 +36,7 @@ export const WorkspacePage: React.FC = () => { workspace={workspace} handleStart={() => workspaceSend("START")} handleStop={() => workspaceSend("STOP")} + handleDelete={() => workspaceSend("DELETE")} handleUpdate={() => workspaceSend("UPDATE")} handleCancel={() => workspaceSend("CANCEL")} resources={resources} diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 075475b45a9d0..7ff246eba31ce 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -40,6 +40,7 @@ export type WorkspaceEvent = | { type: "GET_WORKSPACE"; workspaceId: string } | { type: "START" } | { type: "STOP" } + | { type: "DELETE" } | { type: "UPDATE" } | { type: "CANCEL" } | { type: "LOAD_MORE_BUILDS" } @@ -136,6 +137,7 @@ export const workspaceMachine = createMachine( on: { START: "requestingStart", STOP: "requestingStop", + DELETE: "requestingDelete", UPDATE: "refreshingTemplate", CANCEL: "requestingCancel", }, @@ -170,6 +172,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 +445,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) From e4bf288aa76dae5d327575de061331de2b79015b Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 26 May 2022 22:13:03 +0000 Subject: [PATCH 2/5] Add confirmation dialog --- .../src/pages/WorkspacePage/WorkspacePage.tsx | 25 ++++++++++++++++++- .../xServices/workspace/workspaceXService.ts | 10 +++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 6231bb916e125..e6264eb267317 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 { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { Margins } from "../../components/Margins/Margins" @@ -9,6 +10,12 @@ import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" +const Language = { + deleteDialogTitle: "Delete workspace?", + confirmDelete: "Yes, delete", + deleteDialogMessage: "Deleting your workspace is irreversible. Are you sure?" +} + export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() const workspaceId = firstOrItem(workspaceQueryParam, null) @@ -32,17 +39,33 @@ export const WorkspacePage: React.FC = () => { return ( + <> workspaceSend("START")} handleStop={() => workspaceSend("STOP")} - handleDelete={() => workspaceSend("DELETE")} + handleDelete={() => workspaceSend("ASK_DELETE")} handleUpdate={() => workspaceSend("UPDATE")} handleCancel={() => workspaceSend("CANCEL")} resources={resources} getResourcesError={getResourcesError instanceof Error ? getResourcesError : undefined} builds={builds} /> + { + workspaceSend({ type: "DELETE" }) + }} + onClose={() => { + workspaceSend({ type: "CANCEL_DELETE" }) + }} + description={<>{Language.deleteDialogMessage}} + /> + ) diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index 7ff246eba31ce..efe6e891642ad 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -40,7 +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" } @@ -137,11 +139,17 @@ export const workspaceMachine = createMachine( on: { START: "requestingStart", STOP: "requestingStop", - DELETE: "requestingDelete", + ASK_DELETE: "askingDelete", UPDATE: "refreshingTemplate", CANCEL: "requestingCancel", }, }, + askingDelete: { + on: { + DELETE: "requestingDelete", + CANCEL_DELETE: "idle" + } + }, requestingStart: { entry: "clearBuildError", invoke: { From 44240de048c419dfcb97e8a3215d3630ebf94269 Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 26 May 2022 22:46:04 +0000 Subject: [PATCH 3/5] Extract dialog, storybook it, and test it --- .../DeleteWorkspaceDialog.stories.tsx | 30 ++++++++++++ .../DeleteWorkspaceDialog.tsx | 29 +++++++++++ .../WorkspacePage/WorkspacePage.test.tsx | 12 +++-- .../src/pages/WorkspacePage/WorkspacePage.tsx | 49 +++++++------------ .../xServices/workspace/workspaceXService.ts | 4 +- 5 files changed, 87 insertions(+), 37 deletions(-) create mode 100644 site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.stories.tsx create mode 100644 site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx 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..9ffcef41de1e6 --- /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, +}) => ( + {Language.deleteDialogMessage}} + /> +) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx index 920528c121a7d..d9bc1d362210d 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.test.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, screen, waitFor } from "@testing-library/react" +import { fireEvent, screen, waitFor, within } from "@testing-library/react" import { rest } from "msw" import React from "react" import * as api from "../../api/api" @@ -76,9 +76,15 @@ describe("Workspace Page", () => { const stopWorkspaceMock = jest.spyOn(api, "stopWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) await testButton(Language.stop, stopWorkspaceMock) }) - it("requests a delete job when the user presses Delete", async () => { + it("requests a delete job when the user presses Delete and confirms", async () => { const deleteWorkspaceMock = jest.spyOn(api, "deleteWorkspace").mockResolvedValueOnce(MockWorkspaceBuild) - await testButton(Language.delete, deleteWorkspaceMock) + 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( diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index e6264eb267317..46e733fe003b3 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,7 +1,7 @@ import { useMachine } from "@xstate/react" import React, { useEffect } from "react" import { useParams } from "react-router-dom" -import { ConfirmDialog } from "../../components/ConfirmDialog/ConfirmDialog" +import { DeleteWorkspaceDialog } from "../../components/DeleteWorkspaceDialog/DeleteWorkspaceDialog" import { ErrorSummary } from "../../components/ErrorSummary/ErrorSummary" import { FullScreenLoader } from "../../components/Loader/FullScreenLoader" import { Margins } from "../../components/Margins/Margins" @@ -10,12 +10,6 @@ import { Workspace } from "../../components/Workspace/Workspace" import { firstOrItem } from "../../util/array" import { workspaceMachine } from "../../xServices/workspace/workspaceXService" -const Language = { - deleteDialogTitle: "Delete workspace?", - confirmDelete: "Yes, delete", - deleteDialogMessage: "Deleting your workspace is irreversible. Are you sure?" -} - export const WorkspacePage: React.FC = () => { const { workspace: workspaceQueryParam } = useParams() const workspaceId = firstOrItem(workspaceQueryParam, null) @@ -40,31 +34,22 @@ export const WorkspacePage: React.FC = () => { <> - 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({ type: "DELETE" }) - }} - onClose={() => { - workspaceSend({ type: "CANCEL_DELETE" }) - }} - description={<>{Language.deleteDialogMessage}} - /> + 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("ASK_DELETE")} + handleConfirm={() => workspaceSend("DELETE")} + /> diff --git a/site/src/xServices/workspace/workspaceXService.ts b/site/src/xServices/workspace/workspaceXService.ts index efe6e891642ad..9c2beea1b3876 100644 --- a/site/src/xServices/workspace/workspaceXService.ts +++ b/site/src/xServices/workspace/workspaceXService.ts @@ -147,8 +147,8 @@ export const workspaceMachine = createMachine( askingDelete: { on: { DELETE: "requestingDelete", - CANCEL_DELETE: "idle" - } + CANCEL_DELETE: "idle", + }, }, requestingStart: { entry: "clearBuildError", From a9f18c11ea4b45a98bf695a96716f17d009148a6 Mon Sep 17 00:00:00 2001 From: Presley Date: Thu, 26 May 2022 22:59:05 +0000 Subject: [PATCH 4/5] Fix cancel and redirect --- site/src/pages/WorkspacePage/WorkspacePage.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/site/src/pages/WorkspacePage/WorkspacePage.tsx b/site/src/pages/WorkspacePage/WorkspacePage.tsx index 46e733fe003b3..7129fd99dee7b 100644 --- a/site/src/pages/WorkspacePage/WorkspacePage.tsx +++ b/site/src/pages/WorkspacePage/WorkspacePage.tsx @@ -1,6 +1,6 @@ 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" @@ -12,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) @@ -47,8 +48,11 @@ export const WorkspacePage: React.FC = () => { /> workspaceSend("ASK_DELETE")} - handleConfirm={() => workspaceSend("DELETE")} + handleCancel={() => workspaceSend("CANCEL_DELETE")} + handleConfirm={() => { + workspaceSend("DELETE") + navigate("/workspaces") + }} /> From b9a23558ed48129bffd80d33a12d5a945d796cbc Mon Sep 17 00:00:00 2001 From: Presley Date: Tue, 31 May 2022 14:32:27 +0000 Subject: [PATCH 5/5] Remove fragment --- .../components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx b/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx index 9ffcef41de1e6..9e518d4be7fe7 100644 --- a/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx +++ b/site/src/components/DeleteWorkspaceDialog/DeleteWorkspaceDialog.tsx @@ -24,6 +24,6 @@ export const DeleteWorkspaceDialog: React.FC = ({ title={Language.deleteDialogTitle} onConfirm={handleConfirm} onClose={handleCancel} - description={<>{Language.deleteDialogMessage}} + description={Language.deleteDialogMessage} /> )