mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
feat: New static error summary component (#3107)
This commit is contained in:
@@ -83,3 +83,6 @@ export const getValidationErrorMessage = (error: Error | ApiError | unknown): st
|
||||
isApiError(error) && error.response.data.validations ? error.response.data.validations : []
|
||||
return validationErrors.map((error) => error.detail).join("\n")
|
||||
}
|
||||
|
||||
export const getErrorDetail = (error: Error | ApiError | unknown): string | undefined | null =>
|
||||
isApiError(error) ? error.response.data.detail : error instanceof Error ? error.stack : null
|
||||
|
||||
@@ -23,3 +23,39 @@ WithRetry.args = {
|
||||
}
|
||||
|
||||
export const WithUndefined = Template.bind({})
|
||||
|
||||
export const WithDefaultMessage = Template.bind({})
|
||||
WithDefaultMessage.args = {
|
||||
// Unknown error type
|
||||
error: {
|
||||
message: "Failed to fetch something!",
|
||||
},
|
||||
defaultMessage: "This is a default error message",
|
||||
}
|
||||
|
||||
export const WithDismissible = Template.bind({})
|
||||
WithDismissible.args = {
|
||||
error: {
|
||||
response: {
|
||||
data: {
|
||||
message: "Failed to fetch something!",
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
},
|
||||
dismissible: true,
|
||||
}
|
||||
|
||||
export const WithDetails = Template.bind({})
|
||||
WithDetails.args = {
|
||||
error: {
|
||||
response: {
|
||||
data: {
|
||||
message: "Failed to fetch something!",
|
||||
detail: "The resource you requested does not exist in the database.",
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
},
|
||||
dismissible: true,
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { render, screen } from "@testing-library/react"
|
||||
import { fireEvent, render, screen } from "@testing-library/react"
|
||||
import { ErrorSummary } from "./ErrorSummary"
|
||||
|
||||
describe("ErrorSummary", () => {
|
||||
@@ -8,7 +8,67 @@ describe("ErrorSummary", () => {
|
||||
render(<ErrorSummary error={error} />)
|
||||
|
||||
// Then
|
||||
const element = await screen.findByText("test error message", { exact: false })
|
||||
const element = await screen.findByText("test error message")
|
||||
expect(element).toBeDefined()
|
||||
})
|
||||
|
||||
it("shows details on More click", async () => {
|
||||
// When
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
message: "Failed to fetch something!",
|
||||
detail: "The resource you requested does not exist in the database.",
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
}
|
||||
render(<ErrorSummary error={error} />)
|
||||
|
||||
// Then
|
||||
fireEvent.click(screen.getByText("More"))
|
||||
const element = await screen.findByText(
|
||||
"The resource you requested does not exist in the database.",
|
||||
{ exact: false },
|
||||
)
|
||||
expect(element.closest(".MuiCollapse-entered")).toBeDefined()
|
||||
})
|
||||
|
||||
it("hides details on Less click", async () => {
|
||||
// When
|
||||
const error = {
|
||||
response: {
|
||||
data: {
|
||||
message: "Failed to fetch something!",
|
||||
detail: "The resource you requested does not exist in the database.",
|
||||
},
|
||||
},
|
||||
isAxiosError: true,
|
||||
}
|
||||
render(<ErrorSummary error={error} />)
|
||||
|
||||
// Then
|
||||
fireEvent.click(screen.getByText("More"))
|
||||
fireEvent.click(screen.getByText("Less"))
|
||||
const element = await screen.findByText(
|
||||
"The resource you requested does not exist in the database.",
|
||||
{ exact: false },
|
||||
)
|
||||
expect(element.closest(".MuiCollapse-hidden")).toBeDefined()
|
||||
})
|
||||
|
||||
it("renders nothing on closing", async () => {
|
||||
// When
|
||||
const error = new Error("test error message")
|
||||
render(<ErrorSummary error={error} dismissible />)
|
||||
|
||||
// Then
|
||||
const element = await screen.findByText("test error message")
|
||||
expect(element).toBeDefined()
|
||||
|
||||
const closeIcon = screen.getAllByRole("button")[0]
|
||||
fireEvent.click(closeIcon)
|
||||
const nullElement = screen.queryByText("test error message")
|
||||
expect(nullElement).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,32 +1,125 @@
|
||||
import Button from "@material-ui/core/Button"
|
||||
import Collapse from "@material-ui/core/Collapse"
|
||||
import IconButton from "@material-ui/core/IconButton"
|
||||
import Link from "@material-ui/core/Link"
|
||||
import { darken, makeStyles, Theme } from "@material-ui/core/styles"
|
||||
import CloseIcon from "@material-ui/icons/Close"
|
||||
import RefreshIcon from "@material-ui/icons/Refresh"
|
||||
import { ApiError, getErrorDetail, getErrorMessage } from "api/errors"
|
||||
import { Stack } from "components/Stack/Stack"
|
||||
import { FC } from "react"
|
||||
import { FC, useState } from "react"
|
||||
|
||||
const Language = {
|
||||
retryMessage: "Retry",
|
||||
unknownErrorMessage: "An unknown error has occurred",
|
||||
moreDetails: "More",
|
||||
lessDetails: "Less",
|
||||
}
|
||||
|
||||
export interface ErrorSummaryProps {
|
||||
error: Error | unknown
|
||||
error: ApiError | Error | unknown
|
||||
retry?: () => void
|
||||
dismissible?: boolean
|
||||
defaultMessage?: string
|
||||
}
|
||||
|
||||
export const ErrorSummary: FC<ErrorSummaryProps> = ({ error, retry }) => (
|
||||
<Stack>
|
||||
{!(error instanceof Error) ? (
|
||||
<div>{Language.unknownErrorMessage}</div>
|
||||
) : (
|
||||
<div>{error.toString()}</div>
|
||||
)}
|
||||
export const ErrorSummary: FC<ErrorSummaryProps> = ({
|
||||
error,
|
||||
retry,
|
||||
dismissible,
|
||||
defaultMessage,
|
||||
}) => {
|
||||
const message = getErrorMessage(error, defaultMessage || Language.unknownErrorMessage)
|
||||
const detail = getErrorDetail(error)
|
||||
const [showDetails, setShowDetails] = useState(false)
|
||||
const [isOpen, setOpen] = useState(true)
|
||||
|
||||
{retry && (
|
||||
<div>
|
||||
<Button onClick={retry} startIcon={<RefreshIcon />} variant="outlined">
|
||||
{Language.retryMessage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
const styles = useStyles({ showDetails })
|
||||
|
||||
const toggleShowDetails = () => {
|
||||
setShowDetails(!showDetails)
|
||||
}
|
||||
|
||||
const closeError = () => {
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack className={styles.root}>
|
||||
<Stack direction="row" alignItems="center" className={styles.messageBox}>
|
||||
<div>
|
||||
<span className={styles.errorMessage}>{message}</span>
|
||||
{!!detail && (
|
||||
<Link
|
||||
aria-expanded={showDetails}
|
||||
onClick={toggleShowDetails}
|
||||
className={styles.detailsLink}
|
||||
tabIndex={0}
|
||||
>
|
||||
{showDetails ? Language.lessDetails : Language.moreDetails}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{dismissible && (
|
||||
<IconButton onClick={closeError} className={styles.iconButton}>
|
||||
<CloseIcon className={styles.closeIcon} />
|
||||
</IconButton>
|
||||
)}
|
||||
</Stack>
|
||||
<Collapse in={showDetails}>
|
||||
<div className={styles.details}>{detail}</div>
|
||||
</Collapse>
|
||||
{retry && (
|
||||
<div className={styles.retry}>
|
||||
<Button size="small" onClick={retry} startIcon={<RefreshIcon />} variant="outlined">
|
||||
{Language.retryMessage}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Stack>
|
||||
)
|
||||
}
|
||||
|
||||
interface StyleProps {
|
||||
showDetails?: boolean
|
||||
}
|
||||
|
||||
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
|
||||
root: {
|
||||
background: darken(theme.palette.error.main, 0.6),
|
||||
margin: `${theme.spacing(2)}px`,
|
||||
padding: `${theme.spacing(2)}px`,
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
gap: 0,
|
||||
},
|
||||
messageBox: {
|
||||
justifyContent: "space-between",
|
||||
},
|
||||
errorMessage: {
|
||||
marginRight: `${theme.spacing(1)}px`,
|
||||
},
|
||||
detailsLink: {
|
||||
cursor: "pointer",
|
||||
},
|
||||
details: {
|
||||
marginTop: `${theme.spacing(2)}px`,
|
||||
padding: `${theme.spacing(2)}px`,
|
||||
background: darken(theme.palette.error.main, 0.7),
|
||||
borderRadius: theme.shape.borderRadius,
|
||||
},
|
||||
iconButton: {
|
||||
padding: 0,
|
||||
},
|
||||
closeIcon: {
|
||||
width: 25,
|
||||
height: 25,
|
||||
color: theme.palette.primary.contrastText,
|
||||
},
|
||||
retry: {
|
||||
marginTop: `${theme.spacing(2)}px`,
|
||||
},
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user