mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user