diff --git a/site/src/components/ConfirmDialog/ConfirmDialog.stories.tsx b/site/src/components/ConfirmDialog/ConfirmDialog.stories.tsx new file mode 100644 index 0000000000000..e295a9501aa7b --- /dev/null +++ b/site/src/components/ConfirmDialog/ConfirmDialog.stories.tsx @@ -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 + +const Template: Story = (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", +} diff --git a/site/src/components/ConfirmDialog/ConfirmDialog.test.tsx b/site/src/components/ConfirmDialog/ConfirmDialog.test.tsx new file mode 100644 index 0000000000000..2b95ceb341815 --- /dev/null +++ b/site/src/components/ConfirmDialog/ConfirmDialog.test.tsx @@ -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 = (props: ConfirmDialogProps) => { + return ( + + + + ) + } +} + +describe("ConfirmDialog", () => { + it("renders", () => { + // Given + const onCloseMock = jest.fn() + const props = { + onClose: onCloseMock, + open: true, + title: "Test", + } + + // When + const { getByRole } = render() + + // 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() + + // 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() + + // 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() + + // 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() + + // 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() + 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() + act(() => { + fireEvent.click(getByText("CONFIRM")) + }) + + // Then + expect(onCloseMock).toBeCalledTimes(0) + expect(onConfirmMock).toBeCalledTimes(1) + }) +}) diff --git a/site/src/components/ConfirmDialog/ConfirmDialog.tsx b/site/src/components/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 0000000000000..16ba586eefedc --- /dev/null +++ b/site/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -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 = { + delete: { + confirmText: "Delete", + hideCancel: false, + }, + info: { + confirmText: "OK", + hideCancel: true, + }, +} + +export interface ConfirmDialogProps extends Omit { + 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 = ({ + 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 ( + +
+ + {title} + + + {description && ( + + {description} + + )} +
+ + + + +
+ ) +} diff --git a/site/src/components/Dialog/Dialog.tsx b/site/src/components/Dialog/Dialog.tsx new file mode 100644 index 0000000000000..241ddc64f810e --- /dev/null +++ b/site/src/components/Dialog/Dialog.tsx @@ -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 = ({ title, icon: Icon, superTitle }) => { + const styles = useTitleStyles() + return ( + +
+ {superTitle &&
{superTitle}
} +
{title}
+
+ {Icon && } +
+ ) +} + +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 = ({ + cancelText = "Cancel", + confirmText = "Confirm", + confirmLoading = false, + confirmDialog, + onCancel, + onConfirm, + type = "info", +}) => { + const styles = useButtonStyles({ type }) + + return ( + <> + {onCancel && ( + + {cancelText} + + )} + {onConfirm && ( + + {confirmText} + + )} + + ) +} + +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 + +/** + * 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 = (props) => { + const styles = useSearchStyles() + return ( +
+ + + + } + /> +
+ ) +} + +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 `` becomes + * `` 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 = (props) => { + // Wrapped so we can add custom attributes below + return +} diff --git a/site/src/components/Dialog/types.ts b/site/src/components/Dialog/types.ts new file mode 100644 index 0000000000000..bc827e0b6e4b6 --- /dev/null +++ b/site/src/components/Dialog/types.ts @@ -0,0 +1 @@ +export type ConfirmDialogType = "delete" | "info" diff --git a/site/src/components/Icons/SearchIcon.tsx b/site/src/components/Icons/SearchIcon.tsx new file mode 100644 index 0000000000000..65da1c0b90e05 --- /dev/null +++ b/site/src/components/Icons/SearchIcon.tsx @@ -0,0 +1,8 @@ +import SvgIcon from "@material-ui/core/SvgIcon" +import React from "react" + +export const SearchIcon: typeof SvgIcon = (props) => ( + + + +) diff --git a/site/src/theme/palettes.ts b/site/src/theme/palettes.ts index 312d1144ed32d..00b02eff44253 100644 --- a/site/src/theme/palettes.ts +++ b/site/src/theme/palettes.ts @@ -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",