- {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 0ddd865f27023..80b53d1a7e413 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 (
-
-
+
+
{isCopied ? : }
-
+ {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 0000000000000..1e54293b8ee87
--- /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 0000000000000..7206060e3906d
--- /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 0000000000000..5466459d91c3a
--- /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 0000000000000..19a09cdde4d86
--- /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 0000000000000..9f2c7ec5e9790
--- /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 0000000000000..e41b5a4fdf9f1
--- /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 (
+ }
+ onClick={() => location.replace("/")}
+ >
+ {Language.reloadApp}
+
+ )
+}
+
+/**
+ * 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 97dc042be944d..22bf0dd101a73 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 cd898202a62f6..0ab541c8bc229 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"
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