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
This commit is contained in:
Kira Pilot
2022-10-07 10:39:27 -04:00
committed by GitHub
parent 3cc77d96eb
commit e8e095e2f8
12 changed files with 347 additions and 43 deletions
@@ -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",
}
@@ -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`,
},
}),
}))
@@ -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>
)
}
@@ -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
}
@@ -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] }} />,
},
}
@@ -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 () => {
@@ -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 },
@@ -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 },
+42 -21
View File
@@ -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>
)}
</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>
)}
</>
)
}
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,
},
}))
@@ -1,4 +1,3 @@
import Collapse from "@material-ui/core/Collapse"
import { makeStyles } from "@material-ui/core/styles"
import { Expander } from "components/Expander/Expander"
import { Pill } from "components/Pill/Pill"
@@ -43,17 +42,16 @@ export const LicenseBannerView: React.FC<LicenseBannerViewProps> = ({ warnings }
{Language.upgrade}
</a>
</div>
<Expander expanded={showDetails} setExpanded={setShowDetails} />
<Expander expanded={showDetails} setExpanded={setShowDetails}>
<ul className={styles.list}>
{warnings.map((warning) => (
<li className={styles.listItem} key={`${warning}`}>
{warning}
</li>
))}
</ul>
</Expander>
</div>
<Collapse in={showDetails}>
<ul className={styles.list}>
{warnings.map((warning) => (
<li className={styles.listItem} key={`${warning}`}>
{warning}
</li>
))}
</ul>
</Collapse>
</div>
)
}
+6 -2
View File
@@ -15,7 +15,7 @@ import { WorkspaceScheduleBanner } from "../WorkspaceScheduleBanner/WorkspaceSch
import { WorkspaceScheduleButton } from "../WorkspaceScheduleButton/WorkspaceScheduleButton"
import { WorkspaceSection } from "../WorkspaceSection/WorkspaceSection"
import { WorkspaceStats } from "../WorkspaceStats/WorkspaceStats"
import { WarningAlert } from "../WarningAlert/WarningAlert"
import { AlertBanner } from "../AlertBanner/AlertBanner"
import { useTranslation } from "react-i18next"
export enum WorkspaceErrors {
@@ -87,7 +87,11 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
)
const workspaceRefreshWarning = Boolean(workspaceErrors[WorkspaceErrors.GET_RESOURCES_ERROR]) && (
<WarningAlert text={t("warningsAndErrors.workspaceRefreshWarning")} dismissible />
<AlertBanner
text={t("warningsAndErrors.workspaceRefreshWarning")}
severity="warning"
dismissible
/>
)
return (
@@ -1,7 +1,7 @@
import Button from "@material-ui/core/Button"
import { FC } from "react"
import * as TypesGen from "../../api/typesGenerated"
import { WarningAlert } from "components/WarningAlert/WarningAlert"
import * as TypesGen from "api/typesGenerated"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { useTranslation } from "react-i18next"
import { Maybe } from "components/Conditionals/Maybe"
@@ -15,6 +15,7 @@ export const WorkspaceDeletedBanner: FC<React.PropsWithChildren<WorkspaceDeleted
handleClick,
}) => {
const { t } = useTranslation("workspacePage")
const NewWorkspaceButton = (
<Button onClick={handleClick} size="small">
{t("ctas.createWorkspaceCta")}
@@ -23,9 +24,10 @@ export const WorkspaceDeletedBanner: FC<React.PropsWithChildren<WorkspaceDeleted
return (
<Maybe condition={workspace.latest_build.status === "deleted"}>
<WarningAlert
<AlertBanner
text={t("warningsAndErrors.workspaceDeletedWarning")}
actions={[NewWorkspaceButton]}
severity="warning"
/>
</Maybe>
)
@@ -5,7 +5,7 @@ import utc from "dayjs/plugin/utc"
import { FC } from "react"
import * as TypesGen from "api/typesGenerated"
import { isWorkspaceOn } from "util/workspace"
import { WarningAlert } from "components/WarningAlert/WarningAlert"
import { AlertBanner } from "components/AlertBanner/AlertBanner"
import { useTranslation } from "react-i18next"
dayjs.extend(utc)
@@ -44,9 +44,10 @@ export const WorkspaceScheduleBanner: FC<React.PropsWithChildren<WorkspaceSchedu
)
return (
<WarningAlert
<AlertBanner
text={t("warningsAndErrors.workspaceShutdownWarning")}
actions={[ScheduleButton]}
severity="warning"
/>
)
}
+7 -1
View File
@@ -20,6 +20,12 @@
"incorrectName": "Incorrect {{entity}} name."
},
"ctas": {
"dismissCta": "Dismiss"
"dismissCta": "Dismiss",
"expand": "Click here to learn more",
"collapse": "Click here to hide",
"retry": "Retry"
},
"warningsAndErrors": {
"somethingWentWrong": "Something went wrong."
}
}