Browse Source

Merge pull request #458 from danditomaso/feat/add-error-boundary

feat: add error boundary
deno-round-2
Hunter Thornsberry 1 year ago
committed by GitHub
parent
commit
6e3d326abb
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 5
      bun.lock
  2. 1
      package.json
  3. 1
      public/images/chirpy.svg
  4. 49
      src/App.tsx
  5. 12
      src/PageRouter.tsx
  6. 6
      src/components/Form/DynamicForm.tsx
  7. 4
      src/components/PageComponents/Map/NodeDetail.tsx
  8. 1
      src/components/PageComponents/Messages/Message.tsx
  9. 6
      src/components/PageLayout.tsx
  10. 94
      src/components/UI/ErrorPage.tsx
  11. 6
      src/components/UI/Sidebar/SidebarSection.tsx
  12. 9
      src/components/UI/Typography/H1.tsx
  13. 9
      src/components/UI/Typography/H2.tsx
  14. 9
      src/components/UI/Typography/H3.tsx
  15. 17
      src/components/UI/Typography/H4.tsx
  16. 14
      src/components/UI/Typography/H5.tsx
  17. 30
      src/components/UI/Typography/Heading.tsx
  18. 7
      src/components/UI/Typography/P.tsx
  19. 88
      src/core/utils/github.ts
  20. 4
      src/index.css
  21. 6
      src/pages/Dashboard/index.tsx

5
bun.lock

@ -37,6 +37,7 @@
"maplibre-gl": "5.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-map-gl": "8.0.1",
"react-qrcode-logo": "^3.0.0",
@ -1523,6 +1524,8 @@
"react-dom": ["[email protected]", "", { "dependencies": { "scheduler": "^0.25.0" }, "peerDependencies": { "react": "^19.0.0" } }, "sha512-4GV5sHFG0e/0AD4X+ySy6UJd3jVl1iNsNHdpad0qhABJ11twS3TTBnseqsKurKcsNqCEFeGL3uLpVChpIO3QfQ=="],
"react-error-boundary": ["[email protected]", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "react": ">=16.13.1" } }, "sha512-tnjAxG+IkpLephNcePNA7v6F/QpWLH8He65+DmedchDwg162JZqx4NmbXj0mlAYVVEd81OW7aFhmbsScYfiAFQ=="],
"react-hook-form": ["[email protected]", "", { "peerDependencies": { "react": "^16.8.0 || ^17 || ^18 || ^19" } }, "sha512-eHpAUgUjWbZocoQYUHposymRb4ZP6d0uwUnooL2uOybA9/3tPUvoAKqEWK1WaSiTxxOfTpffNZP7QwlnM3/gEg=="],
"react-is": ["[email protected]", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="],
@ -1543,6 +1546,8 @@
"readable-stream": ["[email protected]", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"regenerator-runtime": ["[email protected]", "", {}, "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="],
"reflect.getprototypeof": ["[email protected]", "", { "dependencies": { "call-bind": "^1.0.8", "define-properties": "^1.2.1", "es-abstract": "^1.23.9", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0", "get-intrinsic": "^1.2.7", "get-proto": "^1.0.1", "which-builtin-type": "^1.2.1" } }, "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw=="],
"regenerate": ["[email protected]", "", {}, "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A=="],

1
package.json

@ -70,6 +70,7 @@
"maplibre-gl": "5.1.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-error-boundary": "^5.0.0",
"react-hook-form": "^7.54.2",
"react-map-gl": "8.0.1",
"react-qrcode-logo": "^3.0.0",

1
public/images/chirpy.svg

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 14 KiB

49
src/App.tsx

@ -12,8 +12,11 @@ import { useAppStore } from "@core/stores/appStore.ts";
import { useDeviceStore } from "@core/stores/deviceStore.ts";
import { Dashboard } from "@pages/Dashboard/index.tsx";
import type { JSX } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "./components/UI/ErrorPage";
import { MapProvider } from "react-map-gl/maplibre";
export const App = (): JSX.Element => {
const { getDevice } = useDeviceStore();
const { selectedDevice, setConnectDialogOpen, connectDialogOpen } =
@ -22,7 +25,7 @@ export const App = (): JSX.Element => {
const device = getDevice(selectedDevice);
return (
<>
<ErrorBoundary FallbackComponent={ErrorPage}>
<NewDeviceDialog
open={connectDialogOpen}
onOpenChange={(open) => {
@ -30,30 +33,30 @@ export const App = (): JSX.Element => {
}}
/>
<Toaster />
<MapProvider>
<DeviceWrapper device={device}>
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
<div className="flex grow">
<DeviceSelector />
<div className="flex grow flex-col">
{device ? (
<div className="flex h-screen">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<DeviceWrapper device={device}>
<div className="flex h-screen flex-col overflow-hidden bg-background-primary text-text-primary">
<div className="flex grow">
<DeviceSelector />
<div className="flex grow flex-col">
{device ? (
<div className="flex h-screen w-full">
<DialogManager />
<KeyBackupReminder />
<CommandPalette />
<MapProvider>
<PageRouter />
</div>
) : (
<>
<Dashboard />
<Footer />
</>
)}
</div>
</MapProvider>
</div>
) : (
<>
<Dashboard />
<Footer />
</>
)}
</div>
</div>
</DeviceWrapper>
</MapProvider>
</>
</div>
</DeviceWrapper>
</ErrorBoundary>
);
};

12
src/PageRouter.tsx

@ -4,16 +4,24 @@ import ChannelsPage from "@pages/Channels.tsx";
import ConfigPage from "@pages/Config/index.tsx";
import MessagesPage from "@pages/Messages.tsx";
import NodesPage from "@pages/Nodes.tsx";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "./components/UI/ErrorPage";
export const ErrorBoundaryWrapper = ({
children,
}: { children: React.ReactNode }) => (
<ErrorBoundary FallbackComponent={ErrorPage}>{children}</ErrorBoundary>
);
export const PageRouter = () => {
const { activePage } = useDevice();
return (
<>
<ErrorBoundary FallbackComponent={ErrorPage}>
{activePage === "messages" && <MessagesPage />}
{activePage === "map" && <MapPage />}
{activePage === "config" && <ConfigPage />}
{activePage === "channels" && <ChannelsPage />}
{activePage === "nodes" && <NodesPage />}
</>
</ErrorBoundary>
);
};

6
src/components/Form/DynamicForm.tsx

@ -4,7 +4,6 @@ import {
} from "@components/Form/DynamicFormField.tsx";
import { FieldWrapper } from "@components/Form/FormWrapper.tsx";
import { Button } from "@components/UI/Button.tsx";
import { H4 } from "@components/UI/Typography/H4.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
type Control,
@ -14,6 +13,7 @@ import {
type SubmitHandler,
useForm,
} from "react-hook-form";
import { Heading } from "../UI/Typography/Heading";
interface DisabledBy<T> {
fieldName: Path<T>;
@ -96,7 +96,9 @@ export function DynamicForm<T extends FieldValues>({
{fieldGroups.map((fieldGroup) => (
<div key={fieldGroup.label} className="space-y-8 sm:space-y-5">
<div>
<H4 className="font-medium">{fieldGroup.label}</H4>
<Heading as="h4" className="font-medium">
{fieldGroup.label}
</Heading>
<Subtle>{fieldGroup.description}</Subtle>
<Subtle className="font-semibold">{fieldGroup?.notes}</Subtle>
</div>

4
src/components/PageComponents/Map/NodeDetail.tsx

@ -1,5 +1,5 @@
import { Separator } from "@app/components/UI/Seperator";
import { H5 } from "@app/components/UI/Typography/H5.tsx";
import { Heading } from "@app/components/UI/Typography/Heading";
import { Subtle } from "@app/components/UI/Typography/Subtle.tsx";
import { formatQuantity } from "@app/core/utils/string";
import { Avatar } from "@components/UI/Avatar";
@ -62,7 +62,7 @@ export const NodeDetail = ({ node }: NodeDetailProps) => {
</div>
<div>
<H5>{name}</H5>
<Heading as="h5">{name}</Heading>
{hardwareType !== "UNSET" && <Subtle>{hardwareType}</Subtle>}

1
src/components/PageComponents/Messages/Message.tsx

@ -5,7 +5,6 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@app/components/UI/Tooltip";
import { useAppStore } from "@app/core/stores/appStore";
import {
type MessageWithState,
useDeviceStore,

6
src/components/PageLayout.tsx

@ -1,5 +1,7 @@
import { cn } from "@app/core/utils/cn.ts";
import { AlignLeftIcon, type LucideIcon } from "lucide-react";
import { ErrorBoundary } from "react-error-boundary";
import { ErrorPage } from "./UI/ErrorPage";
import Footer from "./UI/Footer";
import { Spinner } from "./UI/Spinner";
@ -23,7 +25,7 @@ export const PageLayout = ({
children,
}: PageLayoutProps) => {
return (
<>
<ErrorBoundary FallbackComponent={ErrorPage}>
<div className="relative flex h-full w-full flex-col">
<div className="flex h-14 shrink-0 border-b-[0.5px] border-slate-300 dark:border-slate-700 md:h-16 md:px-4">
<button
@ -68,6 +70,6 @@ export const PageLayout = ({
<Footer />
</div>
</div>
</>
</ErrorBoundary>
);
};

94
src/components/UI/ErrorPage.tsx

@ -0,0 +1,94 @@
import newGithubIssueUrl from "@app/core/utils/github";
import { ExternalLink } from "lucide-react";
import { Heading } from "./Typography/Heading";
import { Link } from "./Typography/Link";
import { P } from "./Typography/P";
export function ErrorPage({ error }: { error: Error }) {
if (!error) {
return null;
}
return (
<article className="w-full overflow-y-auto">
<section className="flex shrink md:flex-row gap-16 mt-20 px-4 md:px-8 text-lg md:text-xl space-y-2 place-items-center">
<div>
<Heading as="h2" className="text-text-primary">
This is a little embarrassing...
</Heading>
<P>
We are really sorry but an error occurred in the web client that
caused it to crash. <br />
This is not supposed to happen, and we are working hard to fix it.
</P>
<P>
The best way to prevent this from happening again to you or anyone
else is to report the issue to us.
</P>
<P>Please include the following information in your report:</P>
<ul className="list-disc list-inside text-sm">
<li>What you were doing when the error occurred</li>
<li>What you expected to happen</li>
<li>What actually happened</li>
<li>Any other relevant information</li>
</ul>
<P>
You can report the issue to our{" "}
<Link
href={newGithubIssueUrl({
repoUrl: "https://github.com/meshtastic/web",
template: "bug.yml",
title: "[Bug]: An unhandled error occurred. <Add details here>",
logs: error?.stack,
})}
>
Github
</Link>
<ExternalLink size={24} className="inline-block ml-2" />
</P>
<P>
Return to the <Link href="/">dashboard</Link>
</P>
</div>
<div className="hidden md:block md:max-w-64 lg:max-w-80 w-full aspect-suqare">
<img
src="/images/chirpy.svg"
alt="Chirpy the Meshtastic error"
className="max-w-full h-auto"
/>
</div>
</section>
<details className="mt-8 px-4 md:px-8 text-lg md:text-xl space-y-2 text-md whitespace-pre-wrap break-all">
<summary className="cursor-pointer">Error Details</summary>
<span className="text-sm mt-4">
{error?.message && (
<>
<label htmlFor="message">Error message:</label>
<p
id="message"
className="text-slate-400 break-words overflow-wrap"
>
{error.message}
</p>
</>
)}
{error?.stack && (
<>
<label htmlFor="stack">Stack trace:</label>
<p
id="stack"
className="text-slate-400 break-words overflow-wrap"
>
{error.stack}
</p>
</>
)}
{!error?.message && !error?.stack && (
<p className="text-slate-400">{error.toString()}</p>
)}
</span>
</details>
</article>
);
}

6
src/components/UI/Sidebar/SidebarSection.tsx

@ -1,4 +1,4 @@
import { H4 } from "@components/UI/Typography/H4.tsx";
import { Heading } from "../Typography/Heading";
export interface SidebarSectionProps {
label: string;
@ -11,7 +11,9 @@ export const SidebarSection = ({
children,
}: SidebarSectionProps) => (
<div className="px-4 py-2">
<H4 className="mb-3 ml-2">{title}</H4>
<Heading as="h4" className="mb-3 ml-2">
{title}
</Heading>
<div className="space-y-1">{children}</div>
</div>
);

9
src/components/UI/Typography/H1.tsx

@ -1,9 +0,0 @@
export interface H1Props {
children: React.ReactNode;
}
export const H1 = ({ children }: H1Props): JSX.Element => (
<h1 className="scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl">
{children}
</h1>
);

9
src/components/UI/Typography/H2.tsx

@ -1,9 +0,0 @@
export interface H2Props {
children: React.ReactNode;
}
export const H2 = ({ children }: H2Props): JSX.Element => (
<h2 className="scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700">
{children}
</h2>
);

9
src/components/UI/Typography/H3.tsx

@ -1,9 +0,0 @@
export interface H3Props {
children: React.ReactNode;
}
export const H3 = ({ children }: H3Props): JSX.Element => (
<h3 className="scroll-m-20 text-2xl font-semibold tracking-tight">
{children}
</h3>
);

17
src/components/UI/Typography/H4.tsx

@ -1,17 +0,0 @@
import { cn } from "@app/core/utils/cn.ts";
export interface H4Props {
className?: string;
children: React.ReactNode;
}
export const H4 = ({ className, children }: H4Props): JSX.Element => (
<h4
className={cn(
"scroll-m-20 text-xl font-semibold tracking-tight",
className,
)}
>
{children}
</h4>
);

14
src/components/UI/Typography/H5.tsx

@ -1,14 +0,0 @@
import { cn } from "@app/core/utils/cn.ts";
export interface H5Props {
className?: string;
children: React.ReactNode;
}
export const H5 = ({ className, children }: H5Props): JSX.Element => (
<h5
className={cn("scroll-m-20 text-lg font-medium tracking-tight", className)}
>
{children}
</h5>
);

30
src/components/UI/Typography/Heading.tsx

@ -0,0 +1,30 @@
import type React from "react";
const headingStyles = {
h1: "scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl",
h2: "scroll-m-20 border-b border-b-slate-200 pb-2 text-3xl font-semibold tracking-tight transition-colors first:mt-0 dark:border-b-slate-700",
h3: "scroll-m-20 text-2xl font-semibold tracking-tight",
h4: "scroll-m-20 text-xl font-semibold tracking-tight",
h5: "scroll-m-20 text-lg font-medium tracking-tight",
};
interface HeadingProps {
as?: "h1" | "h2" | "h3" | "h4" | "h5";
children: React.ReactNode;
className?: string;
}
export const Heading = ({
as: Component = "h1",
children,
className = "",
...props
}: HeadingProps) => {
const baseStyles = headingStyles[Component] || headingStyles.h1;
return (
<Component className={`${baseStyles} ${className}`} {...props}>
{children}
</Component>
);
};

7
src/components/UI/Typography/P.tsx

@ -1,7 +1,10 @@
import { cn } from "@app/core/utils/cn";
export interface PProps {
children: React.ReactNode;
className?: string;
}
export const P = ({ children }: PProps): JSX.Element => (
<p className="leading-7 not-first:mt-6">{children}</p>
export const P = ({ children, className }: PProps) => (
<p className={cn("leading-7 not-first:mt-6", className)}>{children}</p>
);

88
src/core/utils/github.ts

@ -0,0 +1,88 @@
interface RepoIdentifier {
user: string;
repo: string;
}
interface GithubIssueUrlOptions extends Partial<RepoIdentifier> {
repoUrl?: string;
body?: string;
title?: string;
labels?: string[];
template?: string;
assignee?: string;
projects?: string[];
logs?: string;
version?: number;
}
type ValidatedOptions = {
repoUrl: string;
} & Omit<GithubIssueUrlOptions, "repoUrl" | "user" | "repo">;
const VALID_PARAMS = [
"body",
"title",
"labels",
"template",
"assignee",
"projects",
"version",
"logs",
] as const;
/**
* Generates a URL for creating a new GitHub issue
* @param options Configuration options for the GitHub issue URL
* @returns A formatted URL string for creating a new GitHub issue
* @throws {Error} If repository information is missing or invalid
* @throws {TypeError} If labels or projects are not arrays when provided
*/
export default function newGithubIssueUrl(
options: GithubIssueUrlOptions = {},
): string {
const validatedOptions = validateOptions(options);
const url = new URL(`${validatedOptions.repoUrl}/issues/new`);
for (const key of VALID_PARAMS) {
const value = validatedOptions[key];
if (value === undefined) {
continue;
}
if ((key === "labels" || key === "projects") && Array.isArray(value)) {
url.searchParams.set(key, value.join(","));
continue;
}
url.searchParams.set(key, String(value));
}
return url.toString();
}
function validateOptions(options: GithubIssueUrlOptions): ValidatedOptions {
const repoUrl =
options.repoUrl ??
(options.user && options.repo
? `https://github.com/${options.user}/${options.repo}`
: undefined);
if (!repoUrl) {
throw new Error(
"You need to specify either the `repoUrl` option or both the `user` and `repo` options",
);
}
for (const key of ["labels", "projects"] as const) {
const value = options[key];
if (value !== undefined && !Array.isArray(value)) {
throw new TypeError(`The \`${key}\` option should be an array`);
}
}
return {
...options,
repoUrl,
};
}

4
src/index.css

@ -76,6 +76,10 @@
::file-selector-button {
border-color: var(--color-slate-200, currentColor);
}
body {
font-family: var(--font-sans);
}
}
@layer components {

6
src/pages/Dashboard/index.tsx

@ -1,8 +1,8 @@
import { Heading } from "@app/components/UI/Typography/Heading";
import { useAppStore } from "@app/core/stores/appStore.ts";
import { useDeviceStore } from "@app/core/stores/deviceStore.ts";
import { Button } from "@components/UI/Button.tsx";
import { Separator } from "@components/UI/Seperator.tsx";
import { H3 } from "@components/UI/Typography/H3.tsx";
import { Subtle } from "@components/UI/Typography/Subtle.tsx";
import {
BluetoothIcon,
@ -25,7 +25,7 @@ export const Dashboard = () => {
<div className="flex flex-col gap-3 p-3">
<div className="flex items-center justify-between">
<div className="space-y-1">
<H3>Connected Devices</H3>
<Heading as="h3">Connected Devices</Heading>
<Subtle>Manage, connect and disconnect devices</Subtle>
</div>
</div>
@ -89,7 +89,7 @@ export const Dashboard = () => {
) : (
<div className="m-auto flex flex-col gap-3 text-center">
<ListPlusIcon size={48} className="mx-auto text-text-secondary" />
<H3>No Devices</H3>
<Heading as="h3">No Devices</Heading>
<Subtle>Connect at least one device to get started</Subtle>
<Button
className="gap-2 dark:bg-white dark:text-slate-900 dark:hover:text-slate-100"

Loading…
Cancel
Save