chore: Port ConfirmDialog from v1 (#1228)

This commit is contained in:
Bruno Quaresma
2022-05-02 08:17:15 -05:00
committed by GitHub
parent 4ff5734720
commit 2043d1a4cc
7 changed files with 677 additions and 0 deletions

View File

@ -0,0 +1,39 @@
import { ComponentMeta, Story } from "@storybook/react"
import React from "react"
import { ConfirmDialog, ConfirmDialogProps } from "./ConfirmDialog"
export default {
title: "Components/Dialogs/ConfirmDialog",
component: ConfirmDialog,
argTypes: {
onClose: {
action: "onClose",
},
onConfirm: {
action: "onConfirm",
},
open: {
control: "boolean",
defaultValue: true,
},
title: {
defaultValue: "Confirm Dialog",
},
},
} as ComponentMeta<typeof ConfirmDialog>
const Template: Story<ConfirmDialogProps> = (args) => <ConfirmDialog {...args} />
export const DeleteDialog = Template.bind({})
DeleteDialog.args = {
description: "Do you really want to delete me?",
hideCancel: false,
type: "delete",
}
export const InfoDialog = Template.bind({})
InfoDialog.args = {
description: "Information is cool!",
hideCancel: true,
type: "info",
}

View File

@ -0,0 +1,152 @@
import ThemeProvider from "@material-ui/styles/ThemeProvider"
import { fireEvent, render } from "@testing-library/react"
import React from "react"
import { act } from "react-dom/test-utils"
import { light } from "../../theme"
import { ConfirmDialog, ConfirmDialogProps } from "./ConfirmDialog"
namespace Helpers {
export const Component: React.FC<ConfirmDialogProps> = (props: ConfirmDialogProps) => {
return (
<ThemeProvider theme={light}>
<ConfirmDialog {...props} />
</ThemeProvider>
)
}
}
describe("ConfirmDialog", () => {
it("renders", () => {
// Given
const onCloseMock = jest.fn()
const props = {
onClose: onCloseMock,
open: true,
title: "Test",
}
// When
const { getByRole } = render(<Helpers.Component {...props} />)
// Then
expect(getByRole("dialog")).toBeDefined()
})
it("does not display cancel for info dialogs", () => {
// Given (note that info is the default)
const onCloseMock = jest.fn()
const props = {
cancelText: "CANCEL",
onClose: onCloseMock,
open: true,
title: "Test",
}
// When
const { queryByText } = render(<Helpers.Component {...props} />)
// Then
expect(queryByText("CANCEL")).toBeNull()
})
it("can display cancel when normally hidden", () => {
// Given
const onCloseMock = jest.fn()
const props = {
cancelText: "CANCEL",
onClose: onCloseMock,
open: true,
title: "Test",
hideCancel: false,
}
// When
const { getByText } = render(<Helpers.Component {...props} />)
// Then
expect(getByText("CANCEL")).toBeDefined()
})
it("displays cancel for delete dialogs", () => {
// Given
const onCloseMock = jest.fn()
const props: ConfirmDialogProps = {
cancelText: "CANCEL",
onClose: onCloseMock,
open: true,
title: "Test",
type: "delete",
}
// When
const { getByText } = render(<Helpers.Component {...props} />)
// Then
expect(getByText("CANCEL")).toBeDefined()
})
it("can hide cancel when normally visible", () => {
// Given
const onCloseMock = jest.fn()
const props: ConfirmDialogProps = {
cancelText: "CANCEL",
onClose: onCloseMock,
open: true,
title: "Test",
hideCancel: true,
type: "delete",
}
// When
const { queryByText } = render(<Helpers.Component {...props} />)
// Then
expect(queryByText("CANCEL")).toBeNull()
})
it("onClose is called when cancelled", () => {
// Given
const onCloseMock = jest.fn()
const props = {
cancelText: "CANCEL",
hideCancel: false,
onClose: onCloseMock,
open: true,
title: "Test",
}
// When
const { getByText } = render(<Helpers.Component {...props} />)
act(() => {
fireEvent.click(getByText("CANCEL"))
})
// Then
expect(onCloseMock).toBeCalledTimes(1)
})
it("onConfirm is called when confirmed", () => {
// Given
const onCloseMock = jest.fn()
const onConfirmMock = jest.fn()
const props = {
cancelText: "CANCEL",
confirmText: "CONFIRM",
hideCancel: false,
onClose: onCloseMock,
onConfirm: onConfirmMock,
open: true,
title: "Test",
}
// When
const { getByText } = render(<Helpers.Component {...props} />)
act(() => {
fireEvent.click(getByText("CONFIRM"))
})
// Then
expect(onCloseMock).toBeCalledTimes(0)
expect(onConfirmMock).toBeCalledTimes(1)
})
})

View File

@ -0,0 +1,132 @@
import DialogActions from "@material-ui/core/DialogActions"
import { fade, makeStyles } from "@material-ui/core/styles"
import Typography from "@material-ui/core/Typography"
import React, { ReactNode } from "react"
import { Dialog, DialogActionButtons, DialogActionButtonsProps } from "../Dialog/Dialog"
import { ConfirmDialogType } from "../Dialog/types"
interface ConfirmDialogTypeConfig {
confirmText: ReactNode
hideCancel: boolean
}
const CONFIRM_DIALOG_DEFAULTS: Record<ConfirmDialogType, ConfirmDialogTypeConfig> = {
delete: {
confirmText: "Delete",
hideCancel: false,
},
info: {
confirmText: "OK",
hideCancel: true,
},
}
export interface ConfirmDialogProps extends Omit<DialogActionButtonsProps, "color" | "confirmDialog" | "onCancel"> {
readonly description?: React.ReactNode
/**
* hideCancel hides the cancel button when set true, and shows the cancel
* button when set to false. When undefined:
* - cancel is not displayed for "info" dialogs
* - cancel is displayed for "delete" dialogs
*/
readonly hideCancel?: boolean
/**
* onClose is called when canceling (if cancel is showing).
*
* Additionally, if onConfirm is not defined onClose will be used in its place
* when confirming.
*/
readonly onClose: () => void
readonly open: boolean
readonly title: string
}
interface StyleProps {
type: ConfirmDialogType
}
const useStyles = makeStyles((theme) => ({
dialogWrapper: (props: StyleProps) => ({
"& .MuiPaper-root": {
background:
props.type === "info"
? theme.palette.confirmDialog.info.background
: theme.palette.confirmDialog.error.background,
},
}),
dialogContent: (props: StyleProps) => ({
color: props.type === "info" ? theme.palette.confirmDialog.info.text : theme.palette.confirmDialog.error.text,
padding: theme.spacing(6),
textAlign: "center",
}),
titleText: {
marginBottom: theme.spacing(3),
},
description: (props: StyleProps) => ({
color:
props.type === "info"
? fade(theme.palette.confirmDialog.info.text, 0.75)
: fade(theme.palette.confirmDialog.error.text, 0.75),
lineHeight: "160%",
"& strong": {
color:
props.type === "info"
? fade(theme.palette.confirmDialog.info.text, 0.95)
: fade(theme.palette.confirmDialog.error.text, 0.95),
},
}),
}))
/**
* Quick-use version of the Dialog component with slightly alternative styles,
* great to use for dialogs that don't have any interaction beyond yes / no.
*/
export const ConfirmDialog: React.FC<ConfirmDialogProps> = ({
cancelText,
confirmLoading,
confirmText,
description,
hideCancel,
onClose,
onConfirm,
open = false,
title,
type = "info",
}) => {
const styles = useStyles({ type })
const defaults = CONFIRM_DIALOG_DEFAULTS[type]
if (typeof hideCancel === "undefined") {
hideCancel = defaults.hideCancel
}
return (
<Dialog className={styles.dialogWrapper} maxWidth="sm" onClose={onClose} open={open}>
<div className={styles.dialogContent}>
<Typography className={styles.titleText} variant="h3">
{title}
</Typography>
{description && (
<Typography className={styles.description} variant="body2">
{description}
</Typography>
)}
</div>
<DialogActions>
<DialogActionButtons
cancelText={cancelText}
confirmDialog
confirmLoading={confirmLoading}
confirmText={confirmText || defaults.confirmText}
onCancel={!hideCancel ? onClose : undefined}
onConfirm={onConfirm || onClose}
type={type}
/>
</DialogActions>
</Dialog>
)
}

View File

@ -0,0 +1,307 @@
import MuiDialog, { DialogProps as MuiDialogProps } from "@material-ui/core/Dialog"
import MuiDialogTitle from "@material-ui/core/DialogTitle"
import InputAdornment from "@material-ui/core/InputAdornment"
import OutlinedInput, { OutlinedInputProps } from "@material-ui/core/OutlinedInput"
import { darken, fade, makeStyles } from "@material-ui/core/styles"
import SvgIcon from "@material-ui/core/SvgIcon"
import * as React from "react"
import { combineClasses } from "../../util/combineClasses"
import { SearchIcon } from "../Icons/SearchIcon"
import { LoadingButton, LoadingButtonProps } from "../LoadingButton/LoadingButton"
import { ConfirmDialogType } from "./types"
export interface DialogTitleProps {
/** Title for display */
title: React.ReactNode
/** Optional icon to display faded to the right of the title */
icon?: typeof SvgIcon
/** Smaller text to display above the title */
superTitle?: React.ReactNode
}
/**
* Override of Material UI's DialogTitle that allows for a supertitle and background icon
*/
export const DialogTitle: React.FC<DialogTitleProps> = ({ title, icon: Icon, superTitle }) => {
const styles = useTitleStyles()
return (
<MuiDialogTitle disableTypography>
<div className={styles.titleWrapper}>
{superTitle && <div className={styles.superTitle}>{superTitle}</div>}
<div className={styles.title}>{title}</div>
</div>
{Icon && <Icon className={styles.icon} />}
</MuiDialogTitle>
)
}
const useTitleStyles = makeStyles(
(theme) => ({
title: {
position: "relative",
zIndex: 2,
fontSize: theme.typography.h3.fontSize,
fontWeight: theme.typography.h3.fontWeight,
lineHeight: "40px",
display: "flex",
alignItems: "center",
},
superTitle: {
position: "relative",
zIndex: 2,
fontSize: theme.typography.body2.fontSize,
fontWeight: 500,
letterSpacing: 1.5,
textTransform: "uppercase",
},
titleWrapper: {
padding: `${theme.spacing(2)}px 0`,
},
icon: {
height: 84,
width: 84,
color: fade(theme.palette.action.disabled, 0.4),
},
}),
{ name: "CdrDialogTitle" },
)
export interface DialogActionButtonsProps {
/** Text to display in the cancel button */
cancelText?: string
/** Text to display in the confirm button */
confirmText?: React.ReactNode
/** Whether or not confirm is loading, also disables cancel when true */
confirmLoading?: boolean
/** Whether or not this is a confirm dialog */
confirmDialog?: boolean
/** Called when cancel is clicked */
onCancel?: () => void
/** Called when confirm is clicked */
onConfirm?: () => void
type?: ConfirmDialogType
}
const typeToColor = (type: ConfirmDialogType): LoadingButtonProps["color"] => {
if (type === "delete") {
return "secondary"
}
return "primary"
}
/**
* Quickly handels most modals actions, some combination of a cancel and confirm button
*/
export const DialogActionButtons: React.FC<DialogActionButtonsProps> = ({
cancelText = "Cancel",
confirmText = "Confirm",
confirmLoading = false,
confirmDialog,
onCancel,
onConfirm,
type = "info",
}) => {
const styles = useButtonStyles({ type })
return (
<>
{onCancel && (
<LoadingButton
className={combineClasses({
[styles.dialogButton]: true,
[styles.cancelButton]: true,
[styles.confirmDialogCancelButton]: confirmDialog,
})}
disabled={confirmLoading}
onClick={onCancel}
variant="contained"
>
{cancelText}
</LoadingButton>
)}
{onConfirm && (
<LoadingButton
variant="contained"
onClick={onConfirm}
color={typeToColor(type)}
loading={confirmLoading}
type="submit"
className={combineClasses([
styles.dialogButton,
styles.submitButton,
type === "delete" ? styles.errorButton : "",
])}
>
{confirmText}
</LoadingButton>
)}
</>
)
}
interface StyleProps {
type: ConfirmDialogType
}
const useButtonStyles = makeStyles((theme) => ({
dialogButton: {
borderRadius: 0,
fontSize: theme.typography.h6.fontSize,
fontWeight: theme.typography.h5.fontWeight,
padding: theme.spacing(2.25),
width: "100%",
boxShadow: "none",
},
cancelButton: {
background: fade(theme.palette.primary.main, 0.1),
color: theme.palette.primary.main,
"&:hover": {
background: fade(theme.palette.primary.main, 0.3),
},
},
confirmDialogCancelButton: (props: StyleProps) => {
const color = props.type === "info" ? theme.palette.confirmDialog.info.text : theme.palette.confirmDialog.error.text
return {
background: fade(color, 0.15),
color,
"&:hover": {
background: fade(color, 0.3),
},
"&.Mui-disabled": {
background: fade(color, 0.15),
color: fade(color, 0.5),
},
}
},
submitButton: {
// Override disabled to keep background color, change loading spinner to contrast color
"&.Mui-disabled": {
"&.MuiButton-containedPrimary": {
background: theme.palette.primary.dark,
"& .MuiCircularProgress-root": {
color: theme.palette.primary.contrastText,
},
},
"&.CdrButton-error.MuiButton-contained": {
background: darken(theme.palette.error.main, 0.3),
"& .MuiCircularProgress-root": {
color: theme.palette.error.contrastText,
},
},
},
},
errorButton: {
"&.MuiButton-contained": {
backgroundColor: theme.palette.error.main,
color: theme.palette.error.contrastText,
"&:hover": {
backgroundColor: darken(theme.palette.error.main, 0.3),
"@media (hover: none)": {
backgroundColor: "transparent",
},
"&.Mui-disabled": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
backgroundColor: theme.palette.action.disabledBackground,
color: fade(theme.palette.text.disabled, 0.5),
},
},
"&.MuiButton-outlined": {
color: theme.palette.error.main,
borderColor: theme.palette.error.main,
"&:hover": {
backgroundColor: fade(theme.palette.error.main, theme.palette.action.hoverOpacity),
"@media (hover: none)": {
backgroundColor: "transparent",
},
"&.Mui-disabled": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
color: fade(theme.palette.text.disabled, 0.5),
borderColor: theme.palette.action.disabled,
},
},
"&.MuiButton-text": {
color: theme.palette.error.main,
"&:hover": {
backgroundColor: fade(theme.palette.error.main, theme.palette.action.hoverOpacity),
"@media (hover: none)": {
backgroundColor: "transparent",
},
},
"&.Mui-disabled": {
color: fade(theme.palette.text.disabled, 0.5),
},
},
},
}))
export type DialogSearchProps = Omit<OutlinedInputProps, "className" | "fullWidth" | "labelWidth" | "startAdornment">
/**
* Formats a search bar right below the title of a Dialog. Passes all props
* through to the Material UI OutlinedInput component contained within.
*/
export const DialogSearch: React.FC<DialogSearchProps> = (props) => {
const styles = useSearchStyles()
return (
<div className={styles.root}>
<OutlinedInput
{...props}
fullWidth
labelWidth={0}
className={styles.input}
startAdornment={
<InputAdornment position="start">
<SearchIcon className={styles.icon} />
</InputAdornment>
}
/>
</div>
)
}
const useSearchStyles = makeStyles(
(theme) => ({
root: {
position: "relative",
padding: `${theme.spacing(2)}px ${theme.spacing(4)}px`,
boxShadow: `0 2px 6px ${fade("#1D407E", 0.2)}`,
zIndex: 2,
},
input: {
margin: 0,
},
icon: {
width: 16,
height: 16,
},
}),
{ name: "CdrDialogSearch" },
)
export type DialogProps = MuiDialogProps
/**
* Wrapper around Material UI's Dialog component. Conveniently exports all of
* Dialog's components in one import, so for example `<DialogContent />` becomes
* `<Dialog.Content />` etc. Also contains some custom Dialog components listed below.
*
* See original component's Material UI documentation here: https://material-ui.com/components/dialogs/
*/
export const Dialog: React.FC<DialogProps> = (props) => {
// Wrapped so we can add custom attributes below
return <MuiDialog {...props} />
}

View File

@ -0,0 +1 @@
export type ConfirmDialogType = "delete" | "info"

View File

@ -0,0 +1,8 @@
import SvgIcon from "@material-ui/core/SvgIcon"
import React from "react"
export const SearchIcon: typeof SvgIcon = (props) => (
<SvgIcon {...props} viewBox="0 0 16 16">
<path d="M15.707 13.293L13 10.586C13.63 9.536 14 8.311 14 7C14 3.14 10.859 0 7 0C3.141 0 0 3.14 0 7C0 10.86 3.141 14 7 14C8.312 14 9.536 13.631 10.586 13L13.293 15.707C13.488 15.902 13.744 16 14 16C14.256 16 14.512 15.902 14.707 15.707L15.707 14.707C16.098 14.316 16.098 13.684 15.707 13.293ZM7 12C4.239 12 2 9.761 2 7C2 4.239 4.239 2 7 2C9.761 2 12 4.239 12 7C12 9.761 9.761 12 7 12Z" />
</SvgIcon>
)

View File

@ -18,6 +18,16 @@ declare module "@material-ui/core/styles/createPalette" {
contrastText: string
}
}
confirmDialog: {
error: {
background: string
text: string
}
info: {
background: string
text: string
}
}
navbar: {
main: string
}
@ -40,6 +50,16 @@ declare module "@material-ui/core/styles/createPalette" {
contrastText: string
}
}
confirmDialog: {
error: {
background: string
text: string
}
info: {
background: string
text: string
}
}
navbar: {
main: string
}
@ -58,6 +78,7 @@ export type CustomPalette = Pick<
| "action"
| "background"
| "codeBlock"
| "confirmDialog"
| "divider"
| "error"
| "hero"
@ -90,6 +111,16 @@ export const lightPalette: CustomPalette = {
contrastText: "#000",
},
},
confirmDialog: {
error: {
background: "#912F42",
text: "#FFF",
},
info: {
background: "#000",
text: "#FFF",
},
},
primary: {
main: "#519A54",
light: "#A2E0A5",
@ -159,6 +190,13 @@ export const darkPalette: CustomPalette = {
contrastText: "#FFF",
},
},
confirmDialog: {
error: lightPalette.confirmDialog.error,
info: {
background: "rgba(255, 255, 255, 0.95)",
text: "rgb(31, 33, 35)",
},
},
hero: {
main: "#141414",
button: "#333333",