diff --git a/site/src/api/errors.ts b/site/src/api/errors.ts index bc981bbafb256..189ea12f43c4b 100644 --- a/site/src/api/errors.ts +++ b/site/src/api/errors.ts @@ -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 diff --git a/site/src/components/ErrorSummary/ErrorSummary.stories.tsx b/site/src/components/ErrorSummary/ErrorSummary.stories.tsx index 205b08da908a2..02da50d462219 100644 --- a/site/src/components/ErrorSummary/ErrorSummary.stories.tsx +++ b/site/src/components/ErrorSummary/ErrorSummary.stories.tsx @@ -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, +} diff --git a/site/src/components/ErrorSummary/ErrorSummary.test.tsx b/site/src/components/ErrorSummary/ErrorSummary.test.tsx index 4b153f8ebfde5..a6d222e0e3860 100644 --- a/site/src/components/ErrorSummary/ErrorSummary.test.tsx +++ b/site/src/components/ErrorSummary/ErrorSummary.test.tsx @@ -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() // 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() + + // 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() + + // 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() + + // 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() + }) }) diff --git a/site/src/components/ErrorSummary/ErrorSummary.tsx b/site/src/components/ErrorSummary/ErrorSummary.tsx index ec690139660b4..77bea7b056363 100644 --- a/site/src/components/ErrorSummary/ErrorSummary.tsx +++ b/site/src/components/ErrorSummary/ErrorSummary.tsx @@ -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 = ({ error, retry }) => ( - - {!(error instanceof Error) ? ( - {Language.unknownErrorMessage} - ) : ( - {error.toString()} - )} - - {retry && ( - - } variant="outlined"> - {Language.retryMessage} - - - )} - -) +export const ErrorSummary: FC = ({ + 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) + + const styles = useStyles({ showDetails }) + + const toggleShowDetails = () => { + setShowDetails(!showDetails) + } + + const closeError = () => { + setOpen(false) + } + + if (!isOpen) { + return null + } + + return ( + + + + {message} + {!!detail && ( + + {showDetails ? Language.lessDetails : Language.moreDetails} + + )} + + {dismissible && ( + + + + )} + + + {detail} + + {retry && ( + + } variant="outlined"> + {Language.retryMessage} + + + )} + + ) +} + +interface StyleProps { + showDetails?: boolean +} + +const useStyles = makeStyles((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`, + }, +}))