Skip to content

Commit e8e095e

Browse files
authored
feat: redesign error alert (#4403)
* added a warning summary component * added warning to workspace page * consolidated warnings * prettier * updated design * added color scheme * updated expander component * cleanup * fixed tests * fixed height issue * prettier * use theme constants * increased icon margin
1 parent 3cc77d9 commit e8e095e

File tree

12 files changed

+347
-43
lines changed

12 files changed

+347
-43
lines changed
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { Story } from "@storybook/react"
2+
import { AlertBanner, AlertBannerProps } from "./AlertBanner"
3+
import Button from "@material-ui/core/Button"
4+
import { makeMockApiError } from "testHelpers/entities"
5+
6+
export default {
7+
title: "components/AlertBanner",
8+
component: AlertBanner,
9+
}
10+
11+
const ExampleAction = (
12+
<Button onClick={() => null} size="small">
13+
Button
14+
</Button>
15+
)
16+
17+
const mockError = makeMockApiError({
18+
message: "Email or password was invalid",
19+
detail: "Password is invalid",
20+
})
21+
22+
const Template: Story<AlertBannerProps> = (args) => <AlertBanner {...args} />
23+
24+
export const Warning = Template.bind({})
25+
Warning.args = {
26+
text: "This is a warning",
27+
severity: "warning",
28+
}
29+
30+
export const ErrorWithDefaultMessage = Template.bind({})
31+
ErrorWithDefaultMessage.args = {
32+
text: "This is an error",
33+
severity: "error",
34+
}
35+
36+
export const ErrorWithErrorMessage = Template.bind({})
37+
ErrorWithErrorMessage.args = {
38+
error: mockError,
39+
severity: "error",
40+
}
41+
42+
export const WarningWithDismiss = Template.bind({})
43+
WarningWithDismiss.args = {
44+
text: "This is a warning",
45+
dismissible: true,
46+
severity: "warning",
47+
}
48+
49+
export const ErrorWithDismiss = Template.bind({})
50+
ErrorWithDismiss.args = {
51+
error: mockError,
52+
dismissible: true,
53+
severity: "error",
54+
}
55+
56+
export const WarningWithAction = Template.bind({})
57+
WarningWithAction.args = {
58+
text: "This is a warning",
59+
actions: [ExampleAction],
60+
severity: "warning",
61+
}
62+
63+
export const ErrorWithAction = Template.bind({})
64+
ErrorWithAction.args = {
65+
error: mockError,
66+
actions: [ExampleAction],
67+
severity: "error",
68+
}
69+
70+
export const WarningWithActionAndDismiss = Template.bind({})
71+
WarningWithActionAndDismiss.args = {
72+
text: "This is a warning",
73+
actions: [ExampleAction],
74+
dismissible: true,
75+
severity: "warning",
76+
}
77+
78+
export const ErrorWithActionAndDismiss = Template.bind({})
79+
ErrorWithActionAndDismiss.args = {
80+
error: mockError,
81+
actions: [ExampleAction],
82+
dismissible: true,
83+
severity: "error",
84+
}
85+
86+
export const ErrorWithRetry = Template.bind({})
87+
ErrorWithRetry.args = {
88+
error: mockError,
89+
retry: () => null,
90+
dismissible: true,
91+
severity: "error",
92+
}
93+
94+
export const ErrorWithActionRetryAndDismiss = Template.bind({})
95+
ErrorWithActionRetryAndDismiss.args = {
96+
error: mockError,
97+
actions: [ExampleAction],
98+
retry: () => null,
99+
dismissible: true,
100+
severity: "error",
101+
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { useState, FC } from "react"
2+
import Collapse from "@material-ui/core/Collapse"
3+
import { Stack } from "components/Stack/Stack"
4+
import { makeStyles, Theme } from "@material-ui/core/styles"
5+
import { colors } from "theme/colors"
6+
import { useTranslation } from "react-i18next"
7+
import { getErrorDetail, getErrorMessage } from "api/errors"
8+
import { Expander } from "components/Expander/Expander"
9+
import { Severity, AlertBannerProps } from "./alertTypes"
10+
import { severityConstants } from "./severityConstants"
11+
import { AlertBannerCtas } from "./AlertBannerCtas"
12+
13+
/**
14+
* severity: the level of alert severity (see ./severityTypes.ts)
15+
* text: default text to be displayed to the user; useful for warnings or as a fallback error message
16+
* error: should be passed in if the severity is 'Error'; warnings can use 'text' instead
17+
* actions: an array of CTAs passed in by the consumer
18+
* dismissible: determines whether or not the banner should have a `Dismiss` CTA
19+
* retry: a handler to retry the action that spawned the error
20+
*/
21+
export const AlertBanner: FC<AlertBannerProps> = ({
22+
severity,
23+
text,
24+
error,
25+
actions = [],
26+
retry,
27+
dismissible = false,
28+
}) => {
29+
const { t } = useTranslation("common")
30+
31+
const [open, setOpen] = useState(true)
32+
33+
// if an error is passed in, display that error, otherwise
34+
// display the text passed in, e.g. warning text
35+
const alertMessage = getErrorMessage(error, text ?? t("warningsAndErrors.somethingWentWrong"))
36+
37+
// if we have an error, check if there's detail to display
38+
const detail = error ? getErrorDetail(error) : undefined
39+
const classes = useStyles({ severity, hasDetail: Boolean(detail) })
40+
41+
const [showDetails, setShowDetails] = useState(false)
42+
43+
return (
44+
<Collapse in={open}>
45+
<Stack
46+
className={classes.alertContainer}
47+
direction="row"
48+
alignItems="center"
49+
spacing={0}
50+
justifyContent="space-between"
51+
>
52+
<Stack direction="row" alignItems="center" spacing={1}>
53+
{severityConstants[severity].icon}
54+
<Stack spacing={0}>
55+
{alertMessage}
56+
{detail && (
57+
<Expander expanded={showDetails} setExpanded={setShowDetails}>
58+
<div>{detail}</div>
59+
</Expander>
60+
)}
61+
</Stack>
62+
</Stack>
63+
64+
<AlertBannerCtas
65+
actions={actions}
66+
dismissible={dismissible}
67+
retry={retry}
68+
setOpen={setOpen}
69+
/>
70+
</Stack>
71+
</Collapse>
72+
)
73+
}
74+
75+
interface StyleProps {
76+
severity: Severity
77+
hasDetail: boolean
78+
}
79+
80+
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
81+
alertContainer: (props) => ({
82+
borderColor: severityConstants[props.severity].color,
83+
border: `1px solid ${colors.orange[7]}`,
84+
borderRadius: theme.shape.borderRadius,
85+
padding: `${theme.spacing(1)}px ${theme.spacing(2)}px`,
86+
backgroundColor: `${colors.gray[16]}`,
87+
88+
"& svg": {
89+
marginTop: props.hasDetail ? `${theme.spacing(1)}px` : "inherit",
90+
marginRight: `${theme.spacing(1)}px`,
91+
},
92+
}),
93+
}))
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { FC } from "react"
2+
import { AlertBannerProps } from "./alertTypes"
3+
import { Stack } from "components/Stack/Stack"
4+
import Button from "@material-ui/core/Button"
5+
import RefreshIcon from "@material-ui/icons/Refresh"
6+
import { useTranslation } from "react-i18next"
7+
8+
type AlertBannerCtasProps = Pick<AlertBannerProps, "actions" | "dismissible" | "retry"> & {
9+
setOpen: (arg0: boolean) => void
10+
}
11+
12+
export const AlertBannerCtas: FC<AlertBannerCtasProps> = ({
13+
actions = [],
14+
dismissible,
15+
retry,
16+
setOpen,
17+
}) => {
18+
const { t } = useTranslation("common")
19+
20+
return (
21+
<Stack direction="row">
22+
{/* CTAs passed in by the consumer */}
23+
{actions.length > 0 && actions.map((action) => <div key={String(action)}>{action}</div>)}
24+
25+
{/* retry CTA */}
26+
{retry && (
27+
<div>
28+
<Button size="small" onClick={retry} startIcon={<RefreshIcon />} variant="outlined">
29+
{t("ctas.retry")}
30+
</Button>
31+
</div>
32+
)}
33+
34+
{/* close CTA */}
35+
{dismissible && (
36+
<Button size="small" onClick={() => setOpen(false)} variant="outlined">
37+
{t("ctas.dismissCta")}
38+
</Button>
39+
)}
40+
</Stack>
41+
)
42+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { ApiError } from "api/errors"
2+
import { ReactElement } from "react"
3+
4+
export type Severity = "warning" | "error"
5+
6+
export interface AlertBannerProps {
7+
severity: Severity
8+
text?: string
9+
error?: ApiError | Error | unknown
10+
actions?: ReactElement[]
11+
dismissible?: boolean
12+
retry?: () => void
13+
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import ReportProblemOutlinedIcon from "@material-ui/icons/ReportProblemOutlined"
2+
import ErrorOutlineOutlinedIcon from "@material-ui/icons/ErrorOutlineOutlined"
3+
import { colors } from "theme/colors"
4+
import { Severity } from "./alertTypes"
5+
import { ReactElement } from "react"
6+
7+
export const severityConstants: Record<Severity, { color: string; icon: ReactElement }> = {
8+
warning: {
9+
color: colors.orange[7],
10+
icon: <ReportProblemOutlinedIcon fontSize="small" style={{ color: colors.orange[7] }} />,
11+
},
12+
error: {
13+
color: colors.red[7],
14+
icon: <ErrorOutlineOutlinedIcon fontSize="small" style={{ color: colors.red[7] }} />,
15+
},
16+
}

site/src/components/ErrorSummary/ErrorSummary.test.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { fireEvent, render, screen } from "@testing-library/react"
22
import { ErrorSummary } from "./ErrorSummary"
3+
import { i18n } from "i18n"
4+
5+
const { t } = i18n
36

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

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

5054
// Then
51-
fireEvent.click(screen.getByText("More"))
52-
fireEvent.click(screen.getByText("Less"))
55+
const expandText = t("ctas.expand", { ns: "common" })
56+
const collapseText = t("ctas.collapse", { ns: "common" })
57+
58+
fireEvent.click(screen.getByText(expandText))
59+
fireEvent.click(screen.getByText(collapseText))
5360
const element = await screen.findByText(
5461
"The resource you requested does not exist in the database.",
5562
{ exact: false },
Lines changed: 42 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,66 @@
11
import Link from "@material-ui/core/Link"
22
import makeStyles from "@material-ui/core/styles/makeStyles"
33
import { CloseDropdown, OpenDropdown } from "components/DropdownArrows/DropdownArrows"
4-
5-
const Language = {
6-
expand: "More",
7-
collapse: "Less",
8-
}
4+
import { PropsWithChildren, FC } from "react"
5+
import Collapse from "@material-ui/core/Collapse"
6+
import { useTranslation } from "react-i18next"
7+
import { combineClasses } from "util/combineClasses"
98

109
export interface ExpanderProps {
1110
expanded: boolean
1211
setExpanded: (val: boolean) => void
1312
}
1413

15-
export const Expander: React.FC<ExpanderProps> = ({ expanded, setExpanded }) => {
16-
const toggleExpanded = () => setExpanded(!expanded)
14+
export const Expander: FC<PropsWithChildren<ExpanderProps>> = ({
15+
expanded,
16+
setExpanded,
17+
children,
18+
}) => {
1719
const styles = useStyles()
20+
const { t } = useTranslation("common")
21+
22+
const toggleExpanded = () => setExpanded(!expanded)
23+
1824
return (
19-
<Link aria-expanded={expanded} onClick={toggleExpanded} className={styles.expandLink}>
20-
{expanded ? (
21-
<span className={styles.text}>
22-
{Language.collapse}
23-
<CloseDropdown margin={false} />{" "}
24-
</span>
25-
) : (
26-
<span className={styles.text}>
27-
{Language.expand}
28-
<OpenDropdown margin={false} />
29-
</span>
25+
<>
26+
{!expanded && (
27+
<Link onClick={toggleExpanded} className={styles.expandLink}>
28+
<span className={styles.text}>
29+
{t("ctas.expand")}
30+
<OpenDropdown margin={false} />
31+
</span>
32+
</Link>
33+
)}
34+
<Collapse in={expanded}>
35+
<div className={styles.text}>{children}</div>
36+
</Collapse>
37+
{expanded && (
38+
<Link
39+
onClick={toggleExpanded}
40+
className={combineClasses([styles.expandLink, styles.collapseLink])}
41+
>
42+
<span className={styles.text}>
43+
{t("ctas.collapse")}
44+
<CloseDropdown margin={false} />
45+
</span>
46+
</Link>
3047
)}
31-
</Link>
48+
</>
3249
)
3350
}
3451

3552
const useStyles = makeStyles((theme) => ({
3653
expandLink: {
3754
cursor: "pointer",
38-
color: theme.palette.text.primary,
39-
display: "flex",
55+
color: theme.palette.text.secondary,
56+
},
57+
collapseLink: {
58+
marginTop: `${theme.spacing(2)}px`,
4059
},
4160
text: {
4261
display: "flex",
4362
alignItems: "center",
63+
color: theme.palette.text.secondary,
64+
fontSize: theme.typography.caption.fontSize,
4465
},
4566
}))

0 commit comments

Comments
 (0)