Skip to content

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

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Sep 20, 2022
2 changes: 2 additions & 0 deletions site/src/components/Dialogs/ConfirmDialog/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ export const ConfirmDialog: React.FC<React.PropsWithChildren<ConfirmDialogProps>
confirmLoading,
confirmText,
description,
disabled = false,
hideCancel,
onClose,
onConfirm,
Expand Down Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
57 changes: 57 additions & 0 deletions site/src/components/Dialogs/DeleteDialog/DeleteDialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❤️

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()
})
})
83 changes: 66 additions & 17 deletions site/src/components/Dialogs/DeleteDialog/DeleteDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,80 @@
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
}

export const DeleteDialog: React.FC<React.PropsWithChildren<DeleteDialogProps>> = ({
isOpen,
onCancel,
onConfirm,
title,
description,
entity,
info,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What kind of info is this? A description?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, it's intended to help the user identify the entity to delete, so they don't delete the wrong one by accident. @bpmct suggested this on the basis of an article about making the wrong repo private and thus losing all their github stars.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's the orange text here. Note storybook isn't showing buttons; that's been going on for a while and so far doesn't mean the buttons are actually gone.
Screen Shot 2022-09-20 at 4 52 28 PM

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>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love how we're not passing whole strings in so that the language will stay consistent.

<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 changes: 4 additions & 0 deletions site/src/components/Dialogs/Dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
Expand All @@ -94,6 +96,7 @@ export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
confirmText = "Confirm",
confirmLoading = false,
confirmDialog,
disabled = false,
onCancel,
onConfirm,
type = "info",
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
}
Expand Down
7 changes: 7 additions & 0 deletions site/src/i18n/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 changes: 0 additions & 4 deletions site/src/i18n/en/templatePage.json
Original file line number Diff line number Diff line change
@@ -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."
}
3 changes: 1 addition & 2 deletions site/src/i18n/en/workspacePage.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
6 changes: 2 additions & 4 deletions site/src/pages/TemplatePage/TemplatePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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: {
Expand Down Expand Up @@ -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")
}}
Expand Down
Loading