Skip to content

Commit 1b19a09

Browse files
authored
feat: New static error summary component (#3107)
1 parent fd4954b commit 1b19a09

File tree

4 files changed

+213
-21
lines changed

4 files changed

+213
-21
lines changed

site/src/api/errors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,3 +83,6 @@ export const getValidationErrorMessage = (error: Error | ApiError | unknown): st
8383
isApiError(error) && error.response.data.validations ? error.response.data.validations : []
8484
return validationErrors.map((error) => error.detail).join("\n")
8585
}
86+
87+
export const getErrorDetail = (error: Error | ApiError | unknown): string | undefined | null =>
88+
isApiError(error) ? error.response.data.detail : error instanceof Error ? error.stack : null

site/src/components/ErrorSummary/ErrorSummary.stories.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,3 +23,39 @@ WithRetry.args = {
2323
}
2424

2525
export const WithUndefined = Template.bind({})
26+
27+
export const WithDefaultMessage = Template.bind({})
28+
WithDefaultMessage.args = {
29+
// Unknown error type
30+
error: {
31+
message: "Failed to fetch something!",
32+
},
33+
defaultMessage: "This is a default error message",
34+
}
35+
36+
export const WithDismissible = Template.bind({})
37+
WithDismissible.args = {
38+
error: {
39+
response: {
40+
data: {
41+
message: "Failed to fetch something!",
42+
},
43+
},
44+
isAxiosError: true,
45+
},
46+
dismissible: true,
47+
}
48+
49+
export const WithDetails = Template.bind({})
50+
WithDetails.args = {
51+
error: {
52+
response: {
53+
data: {
54+
message: "Failed to fetch something!",
55+
detail: "The resource you requested does not exist in the database.",
56+
},
57+
},
58+
isAxiosError: true,
59+
},
60+
dismissible: true,
61+
}
Lines changed: 62 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from "@testing-library/react"
1+
import { fireEvent, render, screen } from "@testing-library/react"
22
import { ErrorSummary } from "./ErrorSummary"
33

44
describe("ErrorSummary", () => {
@@ -8,7 +8,67 @@ describe("ErrorSummary", () => {
88
render(<ErrorSummary error={error} />)
99

1010
// Then
11-
const element = await screen.findByText("test error message", { exact: false })
11+
const element = await screen.findByText("test error message")
1212
expect(element).toBeDefined()
1313
})
14+
15+
it("shows details on More click", async () => {
16+
// When
17+
const error = {
18+
response: {
19+
data: {
20+
message: "Failed to fetch something!",
21+
detail: "The resource you requested does not exist in the database.",
22+
},
23+
},
24+
isAxiosError: true,
25+
}
26+
render(<ErrorSummary error={error} />)
27+
28+
// Then
29+
fireEvent.click(screen.getByText("More"))
30+
const element = await screen.findByText(
31+
"The resource you requested does not exist in the database.",
32+
{ exact: false },
33+
)
34+
expect(element.closest(".MuiCollapse-entered")).toBeDefined()
35+
})
36+
37+
it("hides details on Less click", async () => {
38+
// When
39+
const error = {
40+
response: {
41+
data: {
42+
message: "Failed to fetch something!",
43+
detail: "The resource you requested does not exist in the database.",
44+
},
45+
},
46+
isAxiosError: true,
47+
}
48+
render(<ErrorSummary error={error} />)
49+
50+
// Then
51+
fireEvent.click(screen.getByText("More"))
52+
fireEvent.click(screen.getByText("Less"))
53+
const element = await screen.findByText(
54+
"The resource you requested does not exist in the database.",
55+
{ exact: false },
56+
)
57+
expect(element.closest(".MuiCollapse-hidden")).toBeDefined()
58+
})
59+
60+
it("renders nothing on closing", async () => {
61+
// When
62+
const error = new Error("test error message")
63+
render(<ErrorSummary error={error} dismissible />)
64+
65+
// Then
66+
const element = await screen.findByText("test error message")
67+
expect(element).toBeDefined()
68+
69+
const closeIcon = screen.getAllByRole("button")[0]
70+
fireEvent.click(closeIcon)
71+
const nullElement = screen.queryByText("test error message")
72+
expect(nullElement).toBeNull()
73+
})
1474
})
Lines changed: 112 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,125 @@
11
import Button from "@material-ui/core/Button"
2+
import Collapse from "@material-ui/core/Collapse"
3+
import IconButton from "@material-ui/core/IconButton"
4+
import Link from "@material-ui/core/Link"
5+
import { darken, makeStyles, Theme } from "@material-ui/core/styles"
6+
import CloseIcon from "@material-ui/icons/Close"
27
import RefreshIcon from "@material-ui/icons/Refresh"
8+
import { ApiError, getErrorDetail, getErrorMessage } from "api/errors"
39
import { Stack } from "components/Stack/Stack"
4-
import { FC } from "react"
10+
import { FC, useState } from "react"
511

612
const Language = {
713
retryMessage: "Retry",
814
unknownErrorMessage: "An unknown error has occurred",
15+
moreDetails: "More",
16+
lessDetails: "Less",
917
}
1018

1119
export interface ErrorSummaryProps {
12-
error: Error | unknown
20+
error: ApiError | Error | unknown
1321
retry?: () => void
22+
dismissible?: boolean
23+
defaultMessage?: string
1424
}
1525

16-
export const ErrorSummary: FC<ErrorSummaryProps> = ({ error, retry }) => (
17-
<Stack>
18-
{!(error instanceof Error) ? (
19-
<div>{Language.unknownErrorMessage}</div>
20-
) : (
21-
<div>{error.toString()}</div>
22-
)}
23-
24-
{retry && (
25-
<div>
26-
<Button onClick={retry} startIcon={<RefreshIcon />} variant="outlined">
27-
{Language.retryMessage}
28-
</Button>
29-
</div>
30-
)}
31-
</Stack>
32-
)
26+
export const ErrorSummary: FC<ErrorSummaryProps> = ({
27+
error,
28+
retry,
29+
dismissible,
30+
defaultMessage,
31+
}) => {
32+
const message = getErrorMessage(error, defaultMessage || Language.unknownErrorMessage)
33+
const detail = getErrorDetail(error)
34+
const [showDetails, setShowDetails] = useState(false)
35+
const [isOpen, setOpen] = useState(true)
36+
37+
const styles = useStyles({ showDetails })
38+
39+
const toggleShowDetails = () => {
40+
setShowDetails(!showDetails)
41+
}
42+
43+
const closeError = () => {
44+
setOpen(false)
45+
}
46+
47+
if (!isOpen) {
48+
return null
49+
}
50+
51+
return (
52+
<Stack className={styles.root}>
53+
<Stack direction="row" alignItems="center" className={styles.messageBox}>
54+
<div>
55+
<span className={styles.errorMessage}>{message}</span>
56+
{!!detail && (
57+
<Link
58+
aria-expanded={showDetails}
59+
onClick={toggleShowDetails}
60+
className={styles.detailsLink}
61+
tabIndex={0}
62+
>
63+
{showDetails ? Language.lessDetails : Language.moreDetails}
64+
</Link>
65+
)}
66+
</div>
67+
{dismissible && (
68+
<IconButton onClick={closeError} className={styles.iconButton}>
69+
<CloseIcon className={styles.closeIcon} />
70+
</IconButton>
71+
)}
72+
</Stack>
73+
<Collapse in={showDetails}>
74+
<div className={styles.details}>{detail}</div>
75+
</Collapse>
76+
{retry && (
77+
<div className={styles.retry}>
78+
<Button size="small" onClick={retry} startIcon={<RefreshIcon />} variant="outlined">
79+
{Language.retryMessage}
80+
</Button>
81+
</div>
82+
)}
83+
</Stack>
84+
)
85+
}
86+
87+
interface StyleProps {
88+
showDetails?: boolean
89+
}
90+
91+
const useStyles = makeStyles<Theme, StyleProps>((theme) => ({
92+
root: {
93+
background: darken(theme.palette.error.main, 0.6),
94+
margin: `${theme.spacing(2)}px`,
95+
padding: `${theme.spacing(2)}px`,
96+
borderRadius: theme.shape.borderRadius,
97+
gap: 0,
98+
},
99+
messageBox: {
100+
justifyContent: "space-between",
101+
},
102+
errorMessage: {
103+
marginRight: `${theme.spacing(1)}px`,
104+
},
105+
detailsLink: {
106+
cursor: "pointer",
107+
},
108+
details: {
109+
marginTop: `${theme.spacing(2)}px`,
110+
padding: `${theme.spacing(2)}px`,
111+
background: darken(theme.palette.error.main, 0.7),
112+
borderRadius: theme.shape.borderRadius,
113+
},
114+
iconButton: {
115+
padding: 0,
116+
},
117+
closeIcon: {
118+
width: 25,
119+
height: 25,
120+
color: theme.palette.primary.contrastText,
121+
},
122+
retry: {
123+
marginTop: `${theme.spacing(2)}px`,
124+
},
125+
}))

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy