From d0fd0d704022454896ca39f007499f7f8341b3fa Mon Sep 17 00:00:00 2001 From: Kira Pilot Date: Fri, 20 May 2022 10:48:39 -0400 Subject: [PATCH] feat: added error boundary (#1602) * added error boundary and error ui components * add body txt and standardize btn size * added story * feat: added error boundary closes #1013 * committing lockfile * added email body to help link --- .vscode/settings.json | 1 + site/package.json | 1 + site/src/app.tsx | 17 +-- site/src/components/CodeBlock/CodeBlock.tsx | 30 +++-- site/src/components/CopyButton/CopyButton.tsx | 28 ++++- .../ErrorBoundary/ErrorBoundary.tsx | 31 +++++ .../RuntimeErrorState/RuntimeErrorReport.tsx | 83 +++++++++++++ .../RuntimeErrorState.stories.tsx | 30 +++++ .../RuntimeErrorState.test.tsx | 43 +++++++ .../RuntimeErrorState/RuntimeErrorState.tsx | 114 ++++++++++++++++++ .../RuntimeErrorState/createCtas.tsx | 72 +++++++++++ site/src/components/Section/Section.tsx | 14 ++- site/yarn.lock | 12 ++ 13 files changed, 454 insertions(+), 22 deletions(-) create mode 100644 site/src/components/ErrorBoundary/ErrorBoundary.tsx create mode 100644 site/src/components/RuntimeErrorState/RuntimeErrorReport.tsx create mode 100644 site/src/components/RuntimeErrorState/RuntimeErrorState.stories.tsx create mode 100644 site/src/components/RuntimeErrorState/RuntimeErrorState.test.tsx create mode 100644 site/src/components/RuntimeErrorState/RuntimeErrorState.tsx create mode 100644 site/src/components/RuntimeErrorState/createCtas.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json index 5c68867059..4aa07eee82 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -52,6 +52,7 @@ "rpty", "sdkproto", "Signup", + "sourcemapped", "stretchr", "TCGETS", "tcpip", diff --git a/site/package.json b/site/package.json index 9cb33d1670..7f3b818262 100644 --- a/site/package.json +++ b/site/package.json @@ -42,6 +42,7 @@ "react": "17.0.2", "react-dom": "17.0.2", "react-router-dom": "6.3.0", + "sourcemapped-stacktrace": "1.1.11", "swr": "1.2.2", "uuid": "8.3.2", "xstate": "4.32.1", diff --git a/site/src/app.tsx b/site/src/app.tsx index a4f0bfa5b5..dd210ef8f0 100644 --- a/site/src/app.tsx +++ b/site/src/app.tsx @@ -4,6 +4,7 @@ import React from "react" import { BrowserRouter as Router } from "react-router-dom" import { SWRConfig } from "swr" import { AppRouter } from "./AppRouter" +import { ErrorBoundary } from "./components/ErrorBoundary/ErrorBoundary" import { GlobalSnackbar } from "./components/GlobalSnackbar/GlobalSnackbar" import { dark } from "./theme" import "./theme/globalFonts" @@ -30,13 +31,15 @@ export const App: React.FC = () => { }, }} > - - - - - - - + + + + + + + + + ) diff --git a/site/src/components/CodeBlock/CodeBlock.tsx b/site/src/components/CodeBlock/CodeBlock.tsx index 2783159802..eb5f6face2 100644 --- a/site/src/components/CodeBlock/CodeBlock.tsx +++ b/site/src/components/CodeBlock/CodeBlock.tsx @@ -5,26 +5,38 @@ import { combineClasses } from "../../util/combineClasses" export interface CodeBlockProps { lines: string[] + ctas?: React.ReactElement[] className?: string } -export const CodeBlock: React.FC = ({ lines, className = "" }) => { +export const CodeBlock: React.FC = ({ lines, ctas, className = "" }) => { const styles = useStyles() return ( -
- {lines.map((line, idx) => ( -
- {line} + <> +
+ {lines.map((line, idx) => ( +
+ {line} +
+ ))} +
+ {ctas && ctas.length && ( +
+ {ctas.map((cta, i) => { + return {cta} + })}
- ))} -
+ )} + ) } const useStyles = makeStyles((theme) => ({ root: { minHeight: 156, + maxHeight: 240, + overflowY: "scroll", background: theme.palette.background.default, color: theme.palette.text.primary, fontFamily: MONOSPACE_FONT_FAMILY, @@ -36,4 +48,8 @@ const useStyles = makeStyles((theme) => ({ line: { whiteSpace: "pre-wrap", }, + ctaBar: { + display: "flex", + justifyContent: "space-between", + }, })) diff --git a/site/src/components/CopyButton/CopyButton.tsx b/site/src/components/CopyButton/CopyButton.tsx index 0ddd865f27..80b53d1a7e 100644 --- a/site/src/components/CopyButton/CopyButton.tsx +++ b/site/src/components/CopyButton/CopyButton.tsx @@ -1,19 +1,27 @@ -import Button from "@material-ui/core/Button" +import IconButton from "@material-ui/core/Button" import { makeStyles } from "@material-ui/core/styles" import Tooltip from "@material-ui/core/Tooltip" import Check from "@material-ui/icons/Check" import React, { useState } from "react" +import { combineClasses } from "../../util/combineClasses" import { FileCopyIcon } from "../Icons/FileCopyIcon" interface CopyButtonProps { text: string - className?: string + ctaCopy?: string + wrapperClassName?: string + buttonClassName?: string } /** * Copy button used inside the CodeBlock component internally */ -export const CopyButton: React.FC = ({ className = "", text }) => { +export const CopyButton: React.FC = ({ + text, + ctaCopy, + wrapperClassName = "", + buttonClassName = "", +}) => { const styles = useStyles() const [isCopied, setIsCopied] = useState(false) @@ -36,10 +44,15 @@ export const CopyButton: React.FC = ({ className = "", text }) return ( -
- + {ctaCopy &&
{ctaCopy}
} +
) @@ -65,4 +78,7 @@ const useStyles = makeStyles((theme) => ({ width: 20, height: 20, }, + buttonCopy: { + marginLeft: theme.spacing(1), + }, })) diff --git a/site/src/components/ErrorBoundary/ErrorBoundary.tsx b/site/src/components/ErrorBoundary/ErrorBoundary.tsx new file mode 100644 index 0000000000..1e54293b8e --- /dev/null +++ b/site/src/components/ErrorBoundary/ErrorBoundary.tsx @@ -0,0 +1,31 @@ +import React from "react" +import { RuntimeErrorState } from "../RuntimeErrorState/RuntimeErrorState" + +type ErrorBoundaryProps = Record + +interface ErrorBoundaryState { + error: Error | null +} + +/** + * Our app's Error Boundary + * Read more about React Error Boundaries: https://reactjs.org/docs/error-boundaries.html + */ +export class ErrorBoundary extends React.Component { + constructor(props: ErrorBoundaryProps) { + super(props) + this.state = { error: null } + } + + static getDerivedStateFromError(error: Error): { error: Error } { + return { error } + } + + render(): React.ReactNode { + if (this.state.error) { + return + } + + return this.props.children + } +} diff --git a/site/src/components/RuntimeErrorState/RuntimeErrorReport.tsx b/site/src/components/RuntimeErrorState/RuntimeErrorReport.tsx new file mode 100644 index 0000000000..7206060e39 --- /dev/null +++ b/site/src/components/RuntimeErrorState/RuntimeErrorReport.tsx @@ -0,0 +1,83 @@ +import { makeStyles } from "@material-ui/core/styles" +import React from "react" +import { CodeBlock } from "../CodeBlock/CodeBlock" +import { createCtas } from "./createCtas" + +const Language = { + reportLoading: "Generating crash report...", +} + +interface ReportState { + error: Error + mappedStack: string[] | null +} + +interface StackTraceAvailableMsg { + type: "stackTraceAvailable" + stackTrace: string[] +} + +/** + * stackTraceUnavailable is a Msg describing a stack trace not being available + */ +export const stackTraceUnavailable = { + type: "stackTraceUnavailable", +} as const + +type ReportMessage = StackTraceAvailableMsg | typeof stackTraceUnavailable + +export const stackTraceAvailable = (stackTrace: string[]): StackTraceAvailableMsg => { + return { + type: "stackTraceAvailable", + stackTrace, + } +} + +const setStackTrace = (model: ReportState, mappedStack: string[]): ReportState => { + return { + ...model, + mappedStack, + } +} + +export const reducer = (model: ReportState, msg: ReportMessage): ReportState => { + switch (msg.type) { + case "stackTraceAvailable": + return setStackTrace(model, msg.stackTrace) + case "stackTraceUnavailable": + return setStackTrace(model, ["Unable to get stack trace"]) + } +} + +export const createFormattedStackTrace = (error: Error, mappedStack: string[] | null): string[] => { + return [ + "======================= STACK TRACE ========================", + "", + error.message, + ...(mappedStack ? mappedStack : []), + "", + "============================================================", + ] +} + +/** + * A code block component that contains the error stack resulting from an error boundary trigger + */ +export const RuntimeErrorReport = ({ error, mappedStack }: ReportState): React.ReactElement => { + const styles = useStyles() + + if (!mappedStack) { + return + } + + const formattedStackTrace = createFormattedStackTrace(error, mappedStack) + return +} + +const useStyles = makeStyles(() => ({ + codeBlock: { + minHeight: "auto", + userSelect: "all", + width: "100%", + }, +})) diff --git a/site/src/components/RuntimeErrorState/RuntimeErrorState.stories.tsx b/site/src/components/RuntimeErrorState/RuntimeErrorState.stories.tsx new file mode 100644 index 0000000000..5466459d91 --- /dev/null +++ b/site/src/components/RuntimeErrorState/RuntimeErrorState.stories.tsx @@ -0,0 +1,30 @@ +import { ComponentMeta, Story } from "@storybook/react" +import React from "react" +import { RuntimeErrorState, RuntimeErrorStateProps } from "./RuntimeErrorState" + +const error = new Error("An error occurred") + +export default { + title: "components/RuntimeErrorState", + component: RuntimeErrorState, + argTypes: { + error: { + defaultValue: error, + }, + }, +} as ComponentMeta + +const Template: Story = (args) => + +export const Errored = Template.bind({}) +Errored.parameters = { + // The RuntimeErrorState is noisy for chromatic, because it renders an actual error + // along with the stacktrace - and the stacktrace includes the full URL of + // scripts in the stack. This is problematic, because every deployment uses + // a different URL, causing the validation to fail. + chromatic: { disableSnapshot: true }, +} + +Errored.args = { + error, +} diff --git a/site/src/components/RuntimeErrorState/RuntimeErrorState.test.tsx b/site/src/components/RuntimeErrorState/RuntimeErrorState.test.tsx new file mode 100644 index 0000000000..19a09cdde4 --- /dev/null +++ b/site/src/components/RuntimeErrorState/RuntimeErrorState.test.tsx @@ -0,0 +1,43 @@ +import { screen } from "@testing-library/react" +import React from "react" +import { render } from "../../testHelpers/renderHelpers" +import { Language as ButtonLanguage } from "./createCtas" +import { Language as RuntimeErrorStateLanguage, RuntimeErrorState } from "./RuntimeErrorState" + +describe("RuntimeErrorState", () => { + beforeEach(() => { + // Given + const errorText = "broken!" + const errorStateProps = { + error: new Error(errorText), + } + + // When + render() + }) + + it("should show stack when encountering runtime error", () => { + // Then + const reportError = screen.getByText("broken!") + expect(reportError).toBeDefined() + + // Despite appearances, this is the stack trace + const stackTrace = screen.getByText("Unable to get stack trace") + expect(stackTrace).toBeDefined() + }) + + it("should have a button bar", () => { + // Then + const copyCta = screen.getByText(ButtonLanguage.copyReport) + expect(copyCta).toBeDefined() + + const reloadCta = screen.getByText(ButtonLanguage.reloadApp) + expect(reloadCta).toBeDefined() + }) + + it("should have an email link", () => { + // Then + const emailLink = screen.getByText(RuntimeErrorStateLanguage.link) + expect(emailLink.closest("a")).toHaveAttribute("href", expect.stringContaining("mailto:support@coder.com")) + }) +}) diff --git a/site/src/components/RuntimeErrorState/RuntimeErrorState.tsx b/site/src/components/RuntimeErrorState/RuntimeErrorState.tsx new file mode 100644 index 0000000000..9f2c7ec5e9 --- /dev/null +++ b/site/src/components/RuntimeErrorState/RuntimeErrorState.tsx @@ -0,0 +1,114 @@ +import Box from "@material-ui/core/Box" +import Link from "@material-ui/core/Link" +import { makeStyles } from "@material-ui/core/styles" +import ErrorOutlineIcon from "@material-ui/icons/ErrorOutline" +import React, { useEffect, useReducer } from "react" +import { mapStackTrace } from "sourcemapped-stacktrace" +import { Margins } from "../Margins/Margins" +import { Section } from "../Section/Section" +import { Typography } from "../Typography/Typography" +import { + createFormattedStackTrace, + reducer, + RuntimeErrorReport, + stackTraceAvailable, + stackTraceUnavailable, +} from "./RuntimeErrorReport" + +export const Language = { + title: "Coder encountered an error", + body: "Please copy the crash log using the button below and", + link: "send it to us.", +} + +export interface RuntimeErrorStateProps { + error: Error +} + +/** + * A title for our error boundary UI + */ +const ErrorStateTitle = () => { + const styles = useStyles() + return ( + + + {Language.title} + + ) +} + +/** + * A description for our error boundary UI + */ +const ErrorStateDescription = ({ emailBody }: { emailBody?: string }) => { + const styles = useStyles() + return ( + + {Language.body}  + + {Language.link} + + + ) +} + +/** + * An error UI that is displayed when our error boundary (ErrorBoundary.tsx) is triggered + */ +export const RuntimeErrorState: React.FC = ({ error }) => { + const styles = useStyles() + const [reportState, dispatch] = useReducer(reducer, { error, mappedStack: null }) + + useEffect(() => { + try { + mapStackTrace(error.stack, (mappedStack) => dispatch(stackTraceAvailable(mappedStack))) + } catch { + dispatch(stackTraceUnavailable) + } + }, [error]) + + return ( + + +
} + description={ + + } + > + +
+
+
+ ) +} + +const useStyles = makeStyles((theme) => ({ + title: { + "& span": { + paddingLeft: theme.spacing(1), + }, + + "& .MuiSvgIcon-root": { + color: theme.palette.error.main, + }, + }, + link: { + textDecoration: "none", + color: theme.palette.primary.main, + }, + reportContainer: { + display: "flex", + justifyContent: "center", + marginTop: theme.spacing(5), + }, +})) diff --git a/site/src/components/RuntimeErrorState/createCtas.tsx b/site/src/components/RuntimeErrorState/createCtas.tsx new file mode 100644 index 0000000000..e41b5a4fdf --- /dev/null +++ b/site/src/components/RuntimeErrorState/createCtas.tsx @@ -0,0 +1,72 @@ +import Button from "@material-ui/core/Button" +import { makeStyles } from "@material-ui/core/styles" +import RefreshIcon from "@material-ui/icons/Refresh" +import React from "react" +import { CopyButton } from "../CopyButton/CopyButton" + +export const Language = { + reloadApp: "Reload Application", + copyReport: "Copy Report", +} + +/** + * A wrapper component for a full-width copy button + */ +const CopyStackButton = ({ text }: { text: string }): React.ReactElement => { + const styles = useStyles() + + return ( + + ) +} + +/** + * A button that reloads our application + */ +const ReloadAppButton = (): React.ReactElement => { + const styles = useStyles() + + return ( + + ) +} + +/** + * createCtas generates an array of buttons to be used with our error boundary UI + */ +export const createCtas = (codeBlock: string[]): React.ReactElement[] => { + // REMARK: we don't have to worry about key order changing + // eslint-disable-next-line react/jsx-key + return [, ] +} + +const useStyles = makeStyles((theme) => ({ + buttonWrapper: { + marginTop: theme.spacing(1), + marginLeft: 0, + flex: theme.spacing(1), + textTransform: "uppercase", + fontSize: theme.typography.fontSize, + }, + + copyButton: { + width: "100%", + marginRight: theme.spacing(1), + backgroundColor: theme.palette.primary.main, + textTransform: "uppercase", + fontSize: theme.typography.fontSize, + }, +})) diff --git a/site/src/components/Section/Section.tsx b/site/src/components/Section/Section.tsx index 97dc042be9..22bf0dd101 100644 --- a/site/src/components/Section/Section.tsx +++ b/site/src/components/Section/Section.tsx @@ -2,6 +2,7 @@ import { makeStyles } from "@material-ui/core/styles" import { fade } from "@material-ui/core/styles/colorManipulator" import Typography from "@material-ui/core/Typography" import React from "react" +import { combineClasses } from "../../util/combineClasses" import { SectionAction } from "../SectionAction/SectionAction" type SectionLayout = "fixed" | "fluid" @@ -12,15 +13,24 @@ export interface SectionProps { toolbar?: React.ReactNode alert?: React.ReactNode layout?: SectionLayout + className?: string children?: React.ReactNode } type SectionFC = React.FC & { Action: typeof SectionAction } -export const Section: SectionFC = ({ title, description, toolbar, alert, children, layout = "fixed" }) => { +export const Section: SectionFC = ({ + title, + description, + toolbar, + alert, + className = "", + children, + layout = "fixed", +}) => { const styles = useStyles({ layout }) return ( -
+
{(title || description) && (
diff --git a/site/yarn.lock b/site/yarn.lock index cd898202a6..0ab541c8bc 100644 --- a/site/yarn.lock +++ b/site/yarn.lock @@ -11991,6 +11991,11 @@ source-map-url@^0.4.0: resolved "https://registry.yarnpkg.com/source-map-url/-/source-map-url-0.4.1.tgz#0af66605a745a5a2f91cf1bbf8a7afbc283dec56" integrity sha512-cPiFOTLUKvJFIg4SKVScy4ilPPW6rFgMgfuZJPNoDuMs3nC1HbMUycBoJw77xFIp6z1UJQJOfx6C9GMH80DiTw== +source-map@0.5.6: + version "0.5.6" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.6.tgz#75ce38f52bf0733c5a7f0c118d81334a2bb5f412" + integrity sha1-dc449SvwczxafwwRjYEzSiu19BI= + source-map@^0.5.0, source-map@^0.5.6, source-map@^0.5.7: version "0.5.7" resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" @@ -12006,6 +12011,13 @@ source-map@^0.7.3, source-map@~0.7.2: resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.7.3.tgz#5302f8169031735226544092e64981f751750383" integrity sha512-CkCj6giN3S+n9qrYiBTX5gystlENnRW5jZeNLHpe6aue+SrHcG5VYwujhW9s4dY31mEGsxBDrHR6oI69fTXsaQ== +sourcemapped-stacktrace@1.1.11: + version "1.1.11" + resolved "https://registry.yarnpkg.com/sourcemapped-stacktrace/-/sourcemapped-stacktrace-1.1.11.tgz#e2dede7fc148599c52a4f883276e527f8452657d" + integrity sha512-O0pcWjJqzQFVsisPlPXuNawJHHg9N9UgpJ/aDmvi9+vnS3x1C0NhwkVFzzZ1VN0Xo+bekyweoqYvBw5ZBKiNnQ== + dependencies: + source-map "0.5.6" + space-separated-tokens@^1.0.0: version "1.1.5" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz#85f32c3d10d9682007e917414ddc5c26d1aa6899"