Skip to content

feat: redesign error alert #4403

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 14 commits into from
Oct 7, 2022
101 changes: 101 additions & 0 deletions site/src/components/AlertBanner/AlertBanner.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Story } from "@storybook/react"
import { AlertBanner, AlertBannerProps } from "./AlertBanner"
import Button from "@material-ui/core/Button"
import { makeMockApiError } from "testHelpers/entities"

export default {
title: "components/AlertBanner",
component: AlertBanner,
}

const ExampleAction = (
<Button onClick={() => null} size="small">
Button
</Button>
)

const mockError = makeMockApiError({
message: "Email or password was invalid",
detail: "Password is invalid",
})

const Template: Story<AlertBannerProps> = (args) => <AlertBanner {...args} />

export const Warning = Template.bind({})
Warning.args = {
text: "This is a warning",
severity: "warning",
}

export const ErrorWithDefaultMessage = Template.bind({})
ErrorWithDefaultMessage.args = {
text: "This is an error",
severity: "error",
}

export const ErrorWithErrorMessage = Template.bind({})
ErrorWithErrorMessage.args = {
error: mockError,
severity: "error",
}

export const WarningWithDismiss = Template.bind({})
WarningWithDismiss.args = {
text: "This is a warning",
dismissible: true,
severity: "warning",
}

export const ErrorWithDismiss = Template.bind({})
ErrorWithDismiss.args = {
error: mockError,
dismissible: true,
severity: "error",
}

export const WarningWithAction = Template.bind({})
WarningWithAction.args = {
text: "This is a warning",
actions: [ExampleAction],
severity: "warning",
}

export const ErrorWithAction = Template.bind({})
ErrorWithAction.args = {
error: mockError,
actions: [ExampleAction],
severity: "error",
}

export const WarningWithActionAndDismiss = Template.bind({})
WarningWithActionAndDismiss.args = {
text: "This is a warning",
actions: [ExampleAction],
dismissible: true,
severity: "warning",
}

export const ErrorWithActionAndDismiss = Template.bind({})
ErrorWithActionAndDismiss.args = {
error: mockError,
actions: [ExampleAction],
dismissible: true,
severity: "error",
}

export const ErrorWithRetry = Template.bind({})
ErrorWithRetry.args = {
error: mockError,
retry: () => null,
dismissible: true,
severity: "error",
}

export const ErrorWithActionRetryAndDismiss = Template.bind({})
ErrorWithActionRetryAndDismiss.args = {
error: mockError,
actions: [ExampleAction],
retry: () => null,
dismissible: true,
severity: "error",
}
93 changes: 93 additions & 0 deletions site/src/components/AlertBanner/AlertBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { useState, FC } from "react"
import Collapse from "@material-ui/core/Collapse"
import { Stack } from "components/Stack/Stack"
import { makeStyles, Theme } from "@material-ui/core/styles"
import { colors } from "theme/colors"
import { useTranslation } from "react-i18next"
import { getErrorDetail, getErrorMessage } from "api/errors"
import { Expander } from "components/Expander/Expander"
import { Severity, AlertBannerProps } from "./alertTypes"
import { severityConstants } from "./severityConstants"
import { AlertBannerCtas } from "./AlertBannerCtas"

/**
* severity: the level of alert severity (see ./severityTypes.ts)
* text: default text to be displayed to the user; useful for warnings or as a fallback error message
* error: should be passed in if the severity is 'Error'; warnings can use 'text' instead
* actions: an array of CTAs passed in by the consumer
* dismissible: determines whether or not the banner should have a `Dismiss` CTA
* retry: a handler to retry the action that spawned the error
*/
export const AlertBanner: FC<AlertBannerProps> = ({
severity,
text,
error,
actions = [],
retry,
dismissible = false,
}) => {
const { t } = useTranslation("common")

const [open, setOpen] = useState(true)

// if an error is passed in, display that error, otherwise
// display the text passed in, e.g. warning text
const alertMessage = getErrorMessage(error, text ?? t("warningsAndErrors.somethingWentWrong"))

// if we have an error, check if there's detail to display
const detail = error ? getErrorDetail(error) : undefined
const classes = useStyles({ severity, hasDetail: Boolean(detail) })

const [showDetails, setShowDetails] = useState(false)

return (
<Collapse in={open}>
<Stack
className={classes.alertContainer}
direction="row"
alignItems="center"
spacing={0}
justifyContent="space-between"
>
<Stack direction="row" alignItems="center" spacing={1}>
{severityConstants[severity].icon}
<Stack spacing={0}>
{alertMessage}
{detail && (
<Expander expanded={showDetails} setExpanded={setShowDetails}>
<div>{detail}</div>
</Expander>
)}
</Stack>
</Stack>

<AlertBannerCtas
actions={actions}
dismissible={dismissible}
retry={retry}
setOpen={setOpen}
/>
</Stack>
</Collapse>
)
}

interface StyleProps {
severity: Severity
hasDetail: boolean
}

const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
alertContainer: (props) => ({
borderColor: severityConstants[props.severity].color,
border: `1px solid ${colors.orange[7]}`,
borderRadius: theme.shape.borderRadius,
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
backgroundColor: `${colors.gray[16]}`,

"& svg": {
marginTop: props.hasDetail ? `${theme.spacing(1)}px` : "inherit",
marginRight: `${theme.spacing(1)}px`,
},
}),
}))
42 changes: 42 additions & 0 deletions site/src/components/AlertBanner/AlertBannerCtas.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { FC } from "react"
import { AlertBannerProps } from "./alertTypes"
import { Stack } from "components/Stack/Stack"
import Button from "@material-ui/core/Button"
import RefreshIcon from "@material-ui/icons/Refresh"
import { useTranslation } from "react-i18next"

type AlertBannerCtasProps = Pick<AlertBannerProps, "actions" | "dismissible" | "retry"> & {
setOpen: (arg0: boolean) => void
}

export const AlertBannerCtas: FC<AlertBannerCtasProps> = ({
actions = [],
dismissible,
retry,
setOpen,
}) => {
const { t } = useTranslation("common")

return (
<Stack direction="row">
{/* CTAs passed in by the consumer */}
{actions.length > 0 && actions.map((action) => <div key={String(action)}>{action}</div>)}

{/* retry CTA */}
{retry && (
<div>
<Button size="small" onClick={retry} startIcon={<RefreshIcon />} variant="outlined">
{t("ctas.retry")}
</Button>
</div>
)}

{/* close CTA */}
{dismissible && (
<Button size="small" onClick={() => setOpen(false)} variant="outlined">
{t("ctas.dismissCta")}
</Button>
)}
</Stack>
)
}
13 changes: 13 additions & 0 deletions site/src/components/AlertBanner/alertTypes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { ApiError } from "api/errors"
import { ReactElement } from "react"

export type Severity = "warning" | "error"

export interface AlertBannerProps {
severity: Severity
text?: string
error?: ApiError | Error | unknown
actions?: ReactElement[]
dismissible?: boolean
retry?: () => void
}
16 changes: 16 additions & 0 deletions site/src/components/AlertBanner/severityConstants.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import ReportProblemOutlinedIcon from "@material-ui/icons/ReportProblemOutlined"
import ErrorOutlineOutlinedIcon from "@material-ui/icons/ErrorOutlineOutlined"
import { colors } from "theme/colors"
import { Severity } from "./alertTypes"
import { ReactElement } from "react"

export const severityConstants: Record<Severity, { color: string; icon: ReactElement }> = {
warning: {
color: colors.orange[7],
icon: <ReportProblemOutlinedIcon fontSize="small" style={{ color: colors.orange[7] }} />,
},
error: {
color: colors.red[7],
icon: <ErrorOutlineOutlinedIcon fontSize="small" style={{ color: colors.red[7] }} />,
},
}
13 changes: 10 additions & 3 deletions site/src/components/ErrorSummary/ErrorSummary.test.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { fireEvent, render, screen } from "@testing-library/react"
import { ErrorSummary } from "./ErrorSummary"
import { i18n } from "i18n"

const { t } = i18n

describe("ErrorSummary", () => {
it("renders", async () => {
Expand All @@ -26,7 +29,8 @@ describe("ErrorSummary", () => {
render(<ErrorSummary error={error} />)

// Then
fireEvent.click(screen.getByText("More"))
const expandText = t("ctas.expand", { ns: "common" })
fireEvent.click(screen.getByText(expandText))
const element = await screen.findByText(
"The resource you requested does not exist in the database.",
{ exact: false },
Expand All @@ -48,8 +52,11 @@ describe("ErrorSummary", () => {
render(<ErrorSummary error={error} />)

// Then
fireEvent.click(screen.getByText("More"))
fireEvent.click(screen.getByText("Less"))
const expandText = t("ctas.expand", { ns: "common" })
const collapseText = t("ctas.collapse", { ns: "common" })

fireEvent.click(screen.getByText(expandText))
fireEvent.click(screen.getByText(collapseText))
const element = await screen.findByText(
"The resource you requested does not exist in the database.",
{ exact: false },
Expand Down
63 changes: 42 additions & 21 deletions site/src/components/Expander/Expander.tsx
Original file line number Diff line number Diff line change
@@ -1,45 +1,66 @@
import Link from "@material-ui/core/Link"
import makeStyles from "@material-ui/core/styles/makeStyles"
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows"

const Language = {
expand: "More",
collapse: "Less",
}
import { PropsWithChildren, FC } from "react"
import Collapse from "@material-ui/core/Collapse"
import { useTranslation } from "react-i18next"
import { combineClasses } from "util/combineClasses"

export interface ExpanderProps {
expanded: boolean
setExpanded: (val: boolean) => void
}

export const Expander: React.FC<ExpanderProps> = ({ expanded, setExpanded }) => {
const toggleExpanded = () => setExpanded(!expanded)
export const Expander: FC<PropsWithChildren<ExpanderProps>> = ({
expanded,
setExpanded,
children,
}) => {
const styles = useStyles()
const { t } = useTranslation("common")

const toggleExpanded = () => setExpanded(!expanded)

return (
<Link aria-expanded={expanded} onClick={toggleExpanded} className={styles.expandLink}>
{expanded ? (
<span className={styles.text}>
{Language.collapse}
<CloseDropdown margin={false} />{" "}
</span>
) : (
<span className={styles.text}>
{Language.expand}
<OpenDropdown margin={false} />
</span>
<>
{!expanded && (
<Link onClick={toggleExpanded} className={styles.expandLink}>
<span className={styles.text}>
{t("ctas.expand")}
<OpenDropdown margin={false} />
</span>
</Link>
)}
<Collapse in={expanded}>
<div className={styles.text}>{children}</div>
</Collapse>
{expanded && (
<Link
onClick={toggleExpanded}
className={combineClasses([styles.expandLink, styles.collapseLink])}
>
<span className={styles.text}>
{t("ctas.collapse")}
<CloseDropdown margin={false} />
</span>
</Link>
)}
</Link>
</>
)
}

const useStyles = makeStyles((theme) => ({
expandLink: {
cursor: "pointer",
color: theme.palette.text.primary,
display: "flex",
color: theme.palette.text.secondary,
},
collapseLink: {
marginTop: `${theme.spacing(2)}px`,
},
text: {
display: "flex",
alignItems: "center",
color: theme.palette.text.secondary,
fontSize: theme.typography.caption.fontSize,
},
}))
Loading