feat: have user type name of thing to delete for extra safety (#4080)

* Add info and text field to delete dialog

* Format

* Use DeleteDialog for Users, nix info except for Workspaces

* Format

* Update storybook

* Add and update tests

* Fix the worst of the UsersPage test bugs

* Fix users page tests

* Fix workspace tests

* Format
This commit is contained in:
Presley Pizzo
2022-09-20 17:13:48 -04:00
committed by GitHub
parent eb71053e56
commit 0899548208
14 changed files with 243 additions and 140 deletions
@@ -83,6 +83,7 @@ export const ConfirmDialog: React.FC<React.PropsWithChildren<ConfirmDialogProps>
confirmLoading,
confirmText,
description,
disabled = false,
hideCancel,
onClose,
onConfirm,
@@ -122,6 +123,7 @@ export const ConfirmDialog: React.FC<React.PropsWithChildren<ConfirmDialogProps>
confirmDialog
confirmLoading={confirmLoading}
confirmText={confirmText || defaults.confirmText}
disabled={disabled}
onCancel={!hideCancel ? onClose : undefined}
onConfirm={onConfirm || onClose}
type={type}
@@ -15,12 +15,14 @@ export default {
control: "boolean",
defaultValue: true,
},
title: {
defaultValue: "Delete Something",
entity: {
defaultValue: "foo",
},
description: {
defaultValue:
"This is irreversible. To confirm, type the name of the thing you want to delete.",
name: {
defaultValue: "MyFoo",
},
info: {
defaultValue: "Here's some info about the foo so you know you're deleting the right one.",
},
},
} as ComponentMeta<typeof DeleteDialog>
@@ -0,0 +1,57 @@
import { screen } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import i18next from "i18next"
import { render } from "testHelpers/renderHelpers"
import { DeleteDialog } from "./DeleteDialog"
describe("DeleteDialog", () => {
it("disables confirm button when the text field is empty", () => {
render(
<DeleteDialog
isOpen
onConfirm={jest.fn()}
onCancel={jest.fn()}
entity="template"
name="MyTemplate"
/>,
)
const confirmButton = screen.getByRole("button", { name: "Delete" })
expect(confirmButton).toBeDisabled()
})
it("disables confirm button when the text field is filled incorrectly", async () => {
const { t } = i18next
render(
<DeleteDialog
isOpen
onConfirm={jest.fn()}
onCancel={jest.fn()}
entity="template"
name="MyTemplate"
/>,
)
const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "template" })
const textField = screen.getByLabelText(labelText)
await userEvent.type(textField, "MyTemplateWrong")
const confirmButton = screen.getByRole("button", { name: "Delete" })
expect(confirmButton).toBeDisabled()
})
it("enables confirm button when the text field is filled correctly", async () => {
const { t } = i18next
render(
<DeleteDialog
isOpen
onConfirm={jest.fn()}
onCancel={jest.fn()}
entity="template"
name="MyTemplate"
/>,
)
const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "template" })
const textField = screen.getByLabelText(labelText)
await userEvent.type(textField, "MyTemplate")
const confirmButton = screen.getByRole("button", { name: "Delete" })
expect(confirmButton).not.toBeDisabled()
})
})
@@ -1,12 +1,20 @@
import React, { ReactNode } from "react"
import FormHelperText from "@material-ui/core/FormHelperText"
import makeStyles from "@material-ui/core/styles/makeStyles"
import TextField from "@material-ui/core/TextField"
import Typography from "@material-ui/core/Typography"
import { Maybe } from "components/Conditionals/Maybe"
import { Stack } from "components/Stack/Stack"
import React, { ChangeEvent, useState } from "react"
import { useTranslation } from "react-i18next"
import { ConfirmDialog } from "../ConfirmDialog/ConfirmDialog"
export interface DeleteDialogProps {
isOpen: boolean
onConfirm: () => void
onCancel: () => void
title: string
description: string | ReactNode
entity: string
name: string
info?: string
confirmLoading?: boolean
}
@@ -14,18 +22,59 @@ export const DeleteDialog: React.FC<React.PropsWithChildren<DeleteDialogProps>>
isOpen,
onCancel,
onConfirm,
title,
description,
entity,
info,
name,
confirmLoading,
}) => (
<ConfirmDialog
type="delete"
hideCancel={false}
open={isOpen}
title={title}
onConfirm={onConfirm}
onClose={onCancel}
description={description}
confirmLoading={confirmLoading}
/>
)
}) => {
const styles = useStyles()
const { t } = useTranslation("common")
const [nameValue, setNameValue] = useState("")
const confirmed = name === nameValue
const handleChange = (event: ChangeEvent<HTMLInputElement>) => {
setNameValue(event.target.value)
}
const content = (
<>
<Typography>{t("deleteDialog.intro", { entity })}</Typography>
<Maybe condition={info !== undefined}>
<Typography className={styles.warning}>{info}</Typography>
</Maybe>
<Typography>{t("deleteDialog.confirm", { entity })}</Typography>
<Stack spacing={1}>
<TextField
name="confirmation"
id="confirmation"
placeholder={name}
value={nameValue}
onChange={handleChange}
label={t("deleteDialog.confirmLabel", { entity })}
/>
<Maybe condition={nameValue.length > 0 && !confirmed}>
<FormHelperText error>{t("deleteDialog.incorrectName", { entity })}</FormHelperText>
</Maybe>
</Stack>
</>
)
return (
<ConfirmDialog
type="delete"
hideCancel={false}
open={isOpen}
title={t("deleteDialog.title", { entity })}
onConfirm={onConfirm}
onClose={onCancel}
description={content}
confirmLoading={confirmLoading}
disabled={!confirmed}
/>
)
}
const useStyles = makeStyles((theme) => ({
warning: {
color: theme.palette.warning.light,
},
}))
+4
View File
@@ -72,6 +72,8 @@ export interface DialogActionButtonsProps {
confirmLoading?: boolean
/** Whether or not this is a confirm dialog */
confirmDialog?: boolean
/** Whether or not the submit button is disabled */
disabled?: boolean
/** Called when cancel is clicked */
onCancel?: () => void
/** Called when confirm is clicked */
@@ -94,6 +96,7 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
confirmText = "Confirm",
confirmLoading = false,
confirmDialog,
disabled = false,
onCancel,
onConfirm,
type = "info",
@@ -122,6 +125,7 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
onClick={onConfirm}
color={typeToColor(type)}
loading={confirmLoading}
disabled={disabled}
type="submit"
className={combineClasses({
[styles.dialogButton]: true,
@@ -45,7 +45,7 @@ export const EnterpriseSnackbar: FC<React.PropsWithChildren<EnterpriseSnackbarPr
<div className={styles.actionWrapper}>
{action}
<IconButton onClick={onClose} className={styles.iconButton}>
<CloseIcon className={styles.closeIcon} />
<CloseIcon className={styles.closeIcon} aria-label="close" />
</IconButton>
</div>
}
+7
View File
@@ -11,5 +11,12 @@
"canceled": "Canceled action",
"failed": "Failed",
"queued": "Queued"
},
"deleteDialog": {
"title": "Delete {{entity}}",
"intro": "Deleting this {{entity}} is irreversible!",
"confirm": "Are you sure you want to proceed? Type the name of this {{entity}} below to confirm.",
"confirmLabel": "Name of {{entity}} to delete",
"incorrectName": "Incorrect {{entity}} name."
}
}
-4
View File
@@ -1,7 +1,3 @@
{
"deleteDialog": {
"title": "Delete template",
"description": "Deleting a template is irreversible. Are you sure you want to proceed?"
},
"deleteSuccess": "Template successfully deleted."
}
+1 -2
View File
@@ -1,7 +1,6 @@
{
"deleteDialog": {
"title": "Delete workspace",
"description": "Deleting a workspace is irreversible. Are you sure you want to proceed?"
"info": "This workspace was created {{timeAgo}}."
},
"workspaceScheduleButton": {
"schedule": "Schedule",
+2 -4
View File
@@ -2,7 +2,6 @@ import { useMachine, useSelector } from "@xstate/react"
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
import { FC, useContext } from "react"
import { Helmet } from "react-helmet-async"
import { useTranslation } from "react-i18next"
import { Navigate, useParams } from "react-router-dom"
import { selectPermissions } from "xServices/auth/authSelectors"
import { XServiceContext } from "xServices/StateContext"
@@ -24,7 +23,6 @@ const useTemplateName = () => {
export const TemplatePage: FC<React.PropsWithChildren<unknown>> = () => {
const organizationId = useOrganizationId()
const { t } = useTranslation("templatePage")
const templateName = useTemplateName()
const [templateState, templateSend] = useMachine(templateMachine, {
context: {
@@ -77,8 +75,8 @@ export const TemplatePage: FC<React.PropsWithChildren<unknown>> = () => {
<DeleteDialog
isOpen={templateState.matches("confirmingDelete")}
confirmLoading={templateState.matches("deleting")}
title={t("deleteDialog.title")}
description={t("deleteDialog.description")}
entity="template"
name={template.name}
onConfirm={() => {
templateSend("CONFIRM_DELETE")
}}
+64 -71
View File
@@ -1,5 +1,7 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import { i18n } from "i18n"
import { rest } from "msw"
import { Language as usersXServiceLanguage } from "xServices/users/usersXService"
import * as API from "../../api/api"
@@ -20,23 +22,20 @@ import { permissionsToCheck } from "../../xServices/auth/authXService"
import { Language as UsersPageLanguage, UsersPage } from "./UsersPage"
import { Language as UsersViewLanguage } from "./UsersPageView"
const { t } = i18n
const suspendUser = async (setupActionSpies: () => void) => {
const user = userEvent.setup()
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}
const moreButtons = await screen.findAllByLabelText("more")
const firstMoreButton = moreButtons[0]
// Click on the "more" button to display the "Suspend" option
const moreButton = within(firstUserRow).getByLabelText("more")
fireEvent.click(moreButton)
await user.click(firstMoreButton)
const menu = await screen.findByRole("menu")
const suspendButton = within(menu).getByText(UsersTableBodyLanguage.suspendMenuItem)
fireEvent.click(suspendButton)
await user.click(suspendButton)
// Check if the confirm message is displayed
const confirmDialog = await screen.findByRole("dialog")
@@ -48,53 +47,51 @@ const suspendUser = async (setupActionSpies: () => void) => {
setupActionSpies()
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByText(UsersPageLanguage.suspendDialogAction)
fireEvent.click(confirmButton)
const confirmButton = await within(confirmDialog).findByText(
UsersPageLanguage.suspendDialogAction,
)
await user.click(confirmButton)
}
const deleteUser = async (setupActionSpies: () => void) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}
const user = userEvent.setup()
// Click on the "more" button to display the "Delete" option
// Needs to await fetching users and fetching permissions, because they're needed to see the more button
const moreButtons = await screen.findAllByLabelText("more")
// get MockUser2
const selectedMoreButton = moreButtons[1]
// Click on the "more" button to display the "Suspend" option
const moreButton = within(firstUserRow).getByLabelText("more")
fireEvent.click(moreButton)
await user.click(selectedMoreButton)
const menu = await screen.findByRole("menu")
const suspendButton = within(menu).getByText(UsersTableBodyLanguage.deleteMenuItem)
const deleteButton = within(menu).getByText(UsersTableBodyLanguage.deleteMenuItem)
fireEvent.click(suspendButton)
await user.click(deleteButton)
// Check if the confirm message is displayed
const confirmDialog = await screen.findByRole("dialog")
expect(confirmDialog).toHaveTextContent(
`${UsersPageLanguage.deleteDialogMessagePrefix} ${MockUser.username}?`,
t("deleteDialog.confirm", { ns: "common", entity: "user" }),
)
// Confirm with text input
const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "user" })
const textField = screen.getByLabelText(labelText)
const dialog = screen.getByRole("dialog")
await user.type(textField, MockUser2.username)
// Setup spies to check the actions after
setupActionSpies()
// Click on the "Confirm" button
const confirmButton = within(confirmDialog).getByText(UsersPageLanguage.deleteDialogAction)
fireEvent.click(confirmButton)
const confirmButton = within(dialog).getByRole("button", { name: "Delete" })
await user.click(confirmButton)
}
const activateUser = async (setupActionSpies: () => void) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[2].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}
// Click on the "more" button to display the "Activate" option
const moreButton = within(firstUserRow).getByLabelText("more")
fireEvent.click(moreButton)
const moreButtons = await screen.findAllByLabelText("more")
const suspendedMoreButton = moreButtons[2]
fireEvent.click(suspendedMoreButton)
const menu = screen.getByRole("menu")
const activateButton = within(menu).getByText(UsersTableBodyLanguage.activateMenuItem)
@@ -115,17 +112,10 @@ const activateUser = async (setupActionSpies: () => void) => {
}
const resetUserPassword = async (setupActionSpies: () => void) => {
// Get the first user in the table
const users = await screen.findAllByText(/.*@coder.com/)
const firstUserRow = users[0].closest("tr")
if (!firstUserRow) {
throw new Error("Error on get the first user row")
}
const moreButtons = await screen.findAllByLabelText("more")
const firstMoreButton = moreButtons[0]
// Click on the "more" button to display the "Suspend" option
const moreButton = within(firstUserRow).getByLabelText("more")
fireEvent.click(moreButton)
fireEvent.click(firstMoreButton)
const menu = screen.getByRole("menu")
const resetPasswordButton = within(menu).getByText(UsersTableBodyLanguage.resetPasswordMenuItem)
@@ -184,13 +174,15 @@ describe("UsersPage", () => {
expect(users.length).toEqual(3)
})
it("shows 'Create user' button to an authorized user", () => {
it("shows 'Create user' button to an authorized user", async () => {
render(<UsersPage />)
const createUserButton = screen.queryByText(UsersViewLanguage.createButton)
const createUserButton = await screen.findByText(UsersViewLanguage.createButton)
// wait for users page to finish loading
await screen.findAllByLabelText("more")
expect(createUserButton).toBeDefined()
})
it("does not show 'Create user' button to unauthorized user", () => {
it("does not show 'Create user' button to unauthorized user", async () => {
server.use(
rest.post("/api/v2/users/:userId/authorization", async (req, res, ctx) => {
const permissions = Object.keys(permissionsToCheck)
@@ -207,6 +199,8 @@ describe("UsersPage", () => {
)
render(<UsersPage />)
const createUserButton = screen.queryByText(UsersViewLanguage.createButton)
// wait for users page to finish loading
await screen.findAllByLabelText("more")
expect(createUserButton).toBeNull()
})
@@ -222,13 +216,11 @@ describe("UsersPage", () => {
await suspendUser(() => {
jest.spyOn(API, "suspendUser").mockResolvedValueOnce(MockUser)
jest
.spyOn(API, "getUsers")
.mockImplementationOnce(() => Promise.resolve([MockUser, MockUser2]))
jest.spyOn(API, "getUsers").mockResolvedValueOnce([SuspendedMockUser, MockUser2])
})
// Check if the success message is displayed
screen.findByText(usersXServiceLanguage.suspendUserSuccess)
await screen.findByText(usersXServiceLanguage.suspendUserSuccess)
// Check if the API was called correctly
expect(API.suspendUser).toBeCalledTimes(1)
@@ -252,7 +244,7 @@ describe("UsersPage", () => {
})
// Check if the error message is displayed
screen.findByText(usersXServiceLanguage.suspendUserError)
await screen.findByText(usersXServiceLanguage.suspendUserError)
// Check if the API was called correctly
expect(API.suspendUser).toBeCalledTimes(1)
@@ -273,20 +265,21 @@ describe("UsersPage", () => {
await deleteUser(() => {
jest.spyOn(API, "deleteUser").mockResolvedValueOnce(undefined)
jest
.spyOn(API, "getUsers")
.mockImplementationOnce(() => Promise.resolve([MockUser, MockUser2]))
jest.spyOn(API, "getUsers").mockResolvedValueOnce([MockUser, SuspendedMockUser])
})
// Check if the success message is displayed
screen.findByText(usersXServiceLanguage.deleteUserSuccess)
await screen.findByText(usersXServiceLanguage.deleteUserSuccess)
// Check if the API was called correctly
expect(API.deleteUser).toBeCalledTimes(1)
expect(API.deleteUser).toBeCalledWith(MockUser.id)
expect(API.deleteUser).toBeCalledWith(MockUser2.id)
// Check if the users list was reload
await waitFor(() => expect(API.getUsers).toBeCalledTimes(1))
// Check if the users list was reloaded
await waitFor(() => {
const users = screen.getAllByLabelText("more")
expect(users.length).toEqual(2)
})
})
})
describe("when it fails", () => {
@@ -303,11 +296,11 @@ describe("UsersPage", () => {
})
// Check if the error message is displayed
screen.findByText(usersXServiceLanguage.deleteUserError)
await screen.findByText(usersXServiceLanguage.deleteUserError)
// Check if the API was called correctly
expect(API.deleteUser).toBeCalledTimes(1)
expect(API.deleteUser).toBeCalledWith(MockUser.id)
expect(API.deleteUser).toBeCalledWith(MockUser2.id)
})
})
})
@@ -330,7 +323,7 @@ describe("UsersPage", () => {
})
// Check if the success message is displayed
screen.findByText(usersXServiceLanguage.activateUserSuccess)
await screen.findByText(usersXServiceLanguage.activateUserSuccess)
// Check if the API was called correctly
expect(API.activateUser).toBeCalledTimes(1)
@@ -351,7 +344,7 @@ describe("UsersPage", () => {
})
// Check if the error message is displayed
screen.findByText(usersXServiceLanguage.activateUserError)
await screen.findByText(usersXServiceLanguage.activateUserError)
// Check if the API was called correctly
expect(API.activateUser).toBeCalledTimes(1)
@@ -375,7 +368,7 @@ describe("UsersPage", () => {
})
// Check if the success message is displayed
screen.findByText(usersXServiceLanguage.resetUserPasswordSuccess)
await screen.findByText(usersXServiceLanguage.resetUserPasswordSuccess)
// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1)
@@ -399,7 +392,7 @@ describe("UsersPage", () => {
})
// Check if the error message is displayed
screen.findByText(usersXServiceLanguage.resetUserPasswordError)
await screen.findByText(usersXServiceLanguage.resetUserPasswordError)
// Check if the API was called correctly
expect(API.updateUserPassword).toBeCalledTimes(1)
@@ -455,7 +448,7 @@ describe("UsersPage", () => {
}, MockAuditorRole)
// Check if the error message is displayed
const errorMessage = screen.findByText(usersXServiceLanguage.updateUserRolesError)
const errorMessage = await screen.findByText(usersXServiceLanguage.updateUserRolesError)
await waitFor(() => expect(errorMessage).toBeDefined())
// Check if the API was called correctly
@@ -485,8 +478,8 @@ describe("UsersPage", () => {
await updateUserRole(() => {}, MockAuditorRole)
// Check if the error message is displayed
const errorMessage = screen.findByText("message from the backend")
await waitFor(() => expect(errorMessage).toBeDefined())
const errorMessage = await screen.findByText("message from the backend")
expect(errorMessage).toBeDefined()
})
})
})
+15 -22
View File
@@ -1,4 +1,5 @@
import { useActor, useMachine } from "@xstate/react"
import { DeleteDialog } from "components/Dialogs/DeleteDialog/DeleteDialog"
import { FC, ReactNode, useContext, useEffect } from "react"
import { Helmet } from "react-helmet-async"
import { useNavigate } from "react-router"
@@ -11,9 +12,6 @@ import { XServiceContext } from "../../xServices/StateContext"
import { UsersPageView } from "./UsersPageView"
export const Language = {
deleteDialogTitle: "Delete user",
deleteDialogAction: "Delete",
deleteDialogMessagePrefix: "Do you want to delete the user",
suspendDialogTitle: "Suspend user",
suspendDialogAction: "Suspend",
suspendDialogMessagePrefix: "Do you want to suspend the user",
@@ -127,25 +125,20 @@ export const UsersPage: FC<{ children?: ReactNode }> = () => {
}}
/>
<ConfirmDialog
type="delete"
hideCancel={false}
open={usersState.matches("confirmUserDeletion")}
confirmLoading={usersState.matches("deletingUser")}
title={Language.deleteDialogTitle}
confirmText={Language.deleteDialogAction}
onConfirm={() => {
usersSend("CONFIRM_USER_DELETE")
}}
onClose={() => {
usersSend("CANCEL_USER_DELETE")
}}
description={
<>
{Language.deleteDialogMessagePrefix} <strong>{userToBeDeleted?.username}</strong>?
</>
}
/>
{userToBeDeleted && (
<DeleteDialog
isOpen={usersState.matches("confirmUserDeletion")}
confirmLoading={usersState.matches("deletingUser")}
name={userToBeDeleted.username}
entity="user"
onConfirm={() => {
usersSend("CONFIRM_USER_DELETE")
}}
onCancel={() => {
usersSend("CANCEL_USER_DELETE")
}}
/>
)}
<ConfirmDialog
type="delete"
@@ -1,5 +1,6 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import { fireEvent, screen, waitFor, within } from "@testing-library/react"
import { fireEvent, screen, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event"
import i18next from "i18next"
import { rest } from "msw"
import * as api from "../../api/api"
@@ -75,13 +76,12 @@ beforeEach(() => {
describe("WorkspacePage", () => {
it("shows a workspace", async () => {
await renderWorkspacePage()
const workspaceName = screen.getByText(MockWorkspace.name)
const workspaceName = await screen.findByText(MockWorkspace.name)
expect(workspaceName).toBeDefined()
})
it("shows the status of the workspace", async () => {
await renderWorkspacePage()
const status = screen.getByRole("status")
const status = await screen.findByRole("status")
expect(status).toHaveTextContent("Running")
// wait for workspace page to finish loading
await screen.findByText("stop")
})
it("requests a stop job when the user presses Stop", async () => {
const stopWorkspaceMock = jest
@@ -91,6 +91,7 @@ describe("WorkspacePage", () => {
})
it("requests a delete job when the user presses Delete and confirms", async () => {
const user = userEvent.setup()
const deleteWorkspaceMock = jest
.spyOn(api, "deleteWorkspace")
.mockResolvedValueOnce(MockWorkspaceBuild)
@@ -98,15 +99,16 @@ describe("WorkspacePage", () => {
// open the workspace action popover so we have access to all available ctas
const trigger = await screen.findByTestId("workspace-actions-button")
fireEvent.click(trigger)
await user.click(trigger)
const button = await screen.findByText(Language.delete)
fireEvent.click(button)
await user.click(button)
const confirmDialog = await screen.findByRole("dialog")
const confirmButton = within(confirmDialog).getByText("Delete")
fireEvent.click(confirmButton)
const labelText = t("deleteDialog.confirmLabel", { ns: "common", entity: "workspace" })
const textField = await screen.findByLabelText(labelText)
await user.type(textField, MockWorkspace.name)
const confirmButton = await screen.findByRole("button", { name: "Delete" })
await user.click(confirmButton)
expect(deleteWorkspaceMock).toBeCalled()
})
@@ -140,8 +140,9 @@ export const WorkspacePage: FC = () => {
buildInfo={buildInfoState.context.buildInfo}
/>
<DeleteDialog
title={t("deleteDialog.title")}
description={t("deleteDialog.description")}
entity="workspace"
name={workspace.name}
info={t("deleteDialog.info", { timeAgo: dayjs(workspace.created_at).fromNow() })}
isOpen={workspaceState.matches({ ready: { build: "askingDelete" } })}
onCancel={() => workspaceSend("CANCEL_DELETE")}
onConfirm={() => {