Skip to content

Commit 815bde0

Browse files
committed
feat: add ErrorBoundary popup, handle missing parent
Adding custom ErrorBoundaries to make errors a bit more aesthetic, and to enable dialogs to navigate to corrective actions. Use this to handle a missing parent. Since missing parents will appear in multiple contexts, introduce a pattern for typed error messages to reliabily re-use.
1 parent 36b645c commit 815bde0

12 files changed

Lines changed: 348 additions & 28 deletions

File tree

CONTRIBUTING.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# Contributing
2+
3+
## Development Overview
4+
5+
- The repository uses [vite](vite.dev) as a build system.
6+
- [Radix-UI](https://www.radix-ui.com/) is the component library used.
7+
- [Tailwind is used for styling](https://tailwindcss.com/)

src/App.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { ResourceSchema } from "./state/openapi";
2+
import { RouteErrorBoundary } from "./components/error_boundary";
23
import SpecSpecifierPage from "./app/spec_specifier/page";
34
import { createBrowserRouter, RouteObject, RouterProvider } from "react-router-dom";
45
import Layout from "./app/explorer/page";
@@ -20,6 +21,7 @@ function createRoutes(resources: ResourceSchema[]): RouteObject[] {
2021
const routes = [{
2122
path: "/",
2223
element: <Layout />,
24+
errorElement: <RouteErrorBoundary />,
2325
children: [
2426
{
2527
path: "/",
@@ -63,7 +65,7 @@ function App() {
6365
function Routes() {
6466
const resources = useAppSelector(selectResources);
6567
return (
66-
<RouterProvider router={createBrowserRouter(createRoutes(resources))} />
68+
<RouterProvider router={createBrowserRouter(createRoutes(resources))} />
6769
)
6870
}
6971

src/app/explorer/info.tsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ type InfoPageProps = {
2121
export default function InfoPage(props: InfoPageProps) {
2222
const params = useParams();
2323
const [state, setState] = useState<ResourceInstance | null>(null);
24-
25-
const childResources = useAppSelector((globalState) =>
24+
25+
const childResources = useAppSelector((globalState) =>
2626
state ? selectChildResources(globalState, props.resource, (state.properties as ResourceProperties)?.path?.split('/').pop() || '') : []
2727
);
2828

@@ -41,16 +41,16 @@ export default function InfoPage(props: InfoPageProps) {
4141
}, [params, props.resource])
4242

4343
const properties = useMemo(() => {
44-
if(state?.properties) {
45-
const results = [];
46-
for (const [key, value] of Object.entries(state.properties as ResourceProperties)) {
47-
results.push(<p key={key}>
48-
<b>{key}:</b> {value}
49-
</p>)
50-
}
51-
return results;
44+
if (state?.properties) {
45+
const results = [];
46+
for (const [key, value] of Object.entries(state.properties as ResourceProperties)) {
47+
results.push(<p key={key}>
48+
<b>{key}:</b> {value}
49+
</p>)
5250
}
53-
return <Spinner />
51+
return results;
52+
}
53+
return <Spinner />
5454
}, [state]);
5555

5656
const customMethods = useMemo(() => {

src/app/explorer/resource_list.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export default function ResourceListPage(props: ResourceListProps) {
5353
</Button>
5454
</div>
5555
</div>
56-
<ResourceListTable
56+
<ResourceListTable
5757
resource={props.resource}
5858
resources={state.resources}
5959
onRefresh={refreshList}

src/components/error_boundary.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import { Component, ErrorInfo, ReactNode } from "react";
2+
import { useRouteError, useNavigate } from "react-router-dom";
3+
import { findErrorHandler } from "@/lib/error_handlers";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/components/ui/dialog";
12+
import { Button } from "@/components/ui/button";
13+
14+
interface ErrorDisplayProps {
15+
error: Error | unknown;
16+
reset: () => void;
17+
}
18+
19+
export function ErrorDisplay({ error, reset }: ErrorDisplayProps) {
20+
const navigate = useNavigate();
21+
const errorMessage = (error as Error)?.message || (typeof error === 'string' ? error : "Something went wrong.");
22+
let title = "An error occurred";
23+
let description = errorMessage;
24+
let action = <Button onClick={reset}>Go Home</Button>;
25+
26+
const handler = findErrorHandler(error);
27+
if (handler) {
28+
title = handler.title(error);
29+
description = handler.description(error);
30+
const actionContent = handler.action(error, { error, reset, navigate });
31+
if (actionContent) {
32+
action = <>{actionContent}</>;
33+
}
34+
}
35+
36+
return (
37+
<Dialog open={true} onOpenChange={() => reset()}>
38+
<DialogContent>
39+
<DialogHeader>
40+
<DialogTitle>{title}</DialogTitle>
41+
<DialogDescription>
42+
{description}
43+
</DialogDescription>
44+
</DialogHeader>
45+
<DialogFooter>
46+
{action}
47+
</DialogFooter>
48+
</DialogContent>
49+
</Dialog>
50+
);
51+
}
52+
53+
interface Props {
54+
children: ReactNode;
55+
}
56+
57+
interface State {
58+
hasError: boolean;
59+
error: Error | null;
60+
}
61+
62+
export class ErrorBoundary extends Component<Props, State> {
63+
public state: State = {
64+
hasError: false,
65+
error: null,
66+
};
67+
68+
public static getDerivedStateFromError(error: Error): State {
69+
return { hasError: true, error };
70+
}
71+
72+
public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
73+
console.error("Uncaught error:", error, errorInfo);
74+
}
75+
76+
private handleClose = () => {
77+
this.setState({ hasError: false, error: null });
78+
window.location.href = "/";
79+
};
80+
81+
public render() {
82+
if (this.state.hasError) {
83+
return <ErrorDisplay error={this.state.error} reset={this.handleClose} />;
84+
}
85+
86+
return this.props.children;
87+
}
88+
}
89+
90+
export function RouteErrorBoundary() {
91+
const error = useRouteError();
92+
const navigate = useNavigate();
93+
94+
const handleClose = () => {
95+
navigate("/");
96+
};
97+
98+
return <ErrorDisplay error={error} reset={handleClose} />;
99+
}

src/components/resource_list.tsx

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { ResourceInstance } from "@/state/fetch";
77
import { useNavigate } from "react-router-dom";
88
import { toast } from "@/hooks/use-toast";
99
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
10+
import { ErrorBoundary } from "@/components/error_boundary";
1011

1112
type ResourceListTableProps = {
1213
resource: ResourceSchema;
@@ -25,7 +26,7 @@ type ColumnDef = {
2526

2627
export function ResourceListTable({ resource, resources, onRefresh }: ResourceListTableProps) {
2728
const navigate = useNavigate();
28-
29+
2930
const dropDownMenuColumn: ColumnDef = {
3031
id: "actions",
3132
accessorKey: "actions",
@@ -65,19 +66,19 @@ export function ResourceListTable({ resource, resources, onRefresh }: ResourceLi
6566
};
6667

6768
function createColumns(r: ResourceSchema | undefined) {
68-
if(r) {
69+
if (r) {
6970
const properties = r.properties();
7071
const columns: ColumnDef[] = properties
7172
.sort((a, b) => {
7273
// First sort by priority (path and id first)
7374
const priorityOrder = { path: 0, id: 1 };
7475
const aPriority = priorityOrder[a.name as keyof typeof priorityOrder] ?? 2;
7576
const bPriority = priorityOrder[b.name as keyof typeof priorityOrder] ?? 2;
76-
77+
7778
if (aPriority !== bPriority) {
7879
return aPriority - bPriority;
7980
}
80-
81+
8182
// Then sort alphabetically
8283
return a.name.localeCompare(b.name);
8384
})
@@ -101,10 +102,10 @@ export function ResourceListTable({ resource, resources, onRefresh }: ResourceLi
101102
async function deleteResource(r: ResourceInstance) {
102103
try {
103104
await r.delete();
104-
toast({description: `Deleted ${r.path}`});
105+
toast({ description: `Deleted ${r.path}` });
105106
onRefresh();
106107
} catch (error) {
107-
toast({description: `Failed to delete resource: ${error instanceof Error ? error.message : String(error)}`});
108+
toast({ description: `Failed to delete resource: ${error instanceof Error ? error.message : String(error)}` });
108109
}
109110
}
110111

@@ -130,9 +131,11 @@ export function ResourceListTable({ resource, resources, onRefresh }: ResourceLi
130131
}
131132

132133
return (
133-
<DataTable
134-
columns={createColumns(resource)}
135-
data={resources}
136-
/>
134+
<ErrorBoundary>
135+
<DataTable
136+
columns={createColumns(resource)}
137+
data={resources}
138+
/>
139+
</ErrorBoundary>
137140
);
138-
}
141+
}

src/components/settings.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ export function Settings() {
6767
);
6868
dispatch(setSchema(new OpenAPI(apiClient)));
6969
} catch (error) {
70-
toast({description: `Failed to auto-load OpenAPI document: ${error}`})
70+
toast({ description: `Failed to auto-load OpenAPI document: ${error}` })
7171
}
7272
}
7373
};
@@ -86,7 +86,7 @@ export function Settings() {
8686
// Persist the spec config
8787
dispatch(setSpecConfig({ url: state.url, prefix: state.prefix || "" }));
8888
} catch (error) {
89-
toast({description: `Failed to fetch OpenAPI document: ${error}`})
89+
toast({ description: `Failed to fetch OpenAPI document: ${error}` })
9090
}
9191
};
9292

src/components/ui/dialog.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import * as React from "react"
2+
import * as DialogPrimitive from "@radix-ui/react-dialog"
3+
import { Cross2Icon } from "@radix-ui/react-icons"
4+
5+
import { cn } from "@/lib/utils"
6+
7+
const Dialog = DialogPrimitive.Root
8+
9+
const DialogTrigger = DialogPrimitive.Trigger
10+
11+
const DialogPortal = DialogPrimitive.Portal
12+
13+
const DialogClose = DialogPrimitive.Close
14+
15+
const DialogOverlay = React.forwardRef<
16+
React.ElementRef<typeof DialogPrimitive.Overlay>,
17+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
18+
>(({ className, ...props }, ref) => (
19+
<DialogPrimitive.Overlay
20+
ref={ref}
21+
className={cn(
22+
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
23+
className
24+
)}
25+
{...props}
26+
/>
27+
))
28+
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
29+
30+
const DialogContent = React.forwardRef<
31+
React.ElementRef<typeof DialogPrimitive.Content>,
32+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
33+
>(({ className, children, ...props }, ref) => (
34+
<DialogPortal>
35+
<DialogOverlay />
36+
<DialogPrimitive.Content
37+
ref={ref}
38+
className={cn(
39+
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
40+
className
41+
)}
42+
{...props}
43+
>
44+
{children}
45+
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
46+
<Cross2Icon className="h-4 w-4" />
47+
<span className="sr-only">Close</span>
48+
</DialogPrimitive.Close>
49+
</DialogPrimitive.Content>
50+
</DialogPortal>
51+
))
52+
DialogContent.displayName = DialogPrimitive.Content.displayName
53+
54+
const DialogHeader = ({
55+
className,
56+
...props
57+
}: React.HTMLAttributes<HTMLDivElement>) => (
58+
<div
59+
className={cn(
60+
"flex flex-col space-y-1.5 text-center sm:text-left",
61+
className
62+
)}
63+
{...props}
64+
/>
65+
)
66+
DialogHeader.displayName = "DialogHeader"
67+
68+
const DialogFooter = ({
69+
className,
70+
...props
71+
}: React.HTMLAttributes<HTMLDivElement>) => (
72+
<div
73+
className={cn(
74+
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
75+
className
76+
)}
77+
{...props}
78+
/>
79+
)
80+
DialogFooter.displayName = "DialogFooter"
81+
82+
const DialogTitle = React.forwardRef<
83+
React.ElementRef<typeof DialogPrimitive.Title>,
84+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
85+
>(({ className, ...props }, ref) => (
86+
<DialogPrimitive.Title
87+
ref={ref}
88+
className={cn(
89+
"text-lg font-semibold leading-none tracking-tight",
90+
className
91+
)}
92+
{...props}
93+
/>
94+
))
95+
DialogTitle.displayName = DialogPrimitive.Title.displayName
96+
97+
const DialogDescription = React.forwardRef<
98+
React.ElementRef<typeof DialogPrimitive.Description>,
99+
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
100+
>(({ className, ...props }, ref) => (
101+
<DialogPrimitive.Description
102+
ref={ref}
103+
className={cn("text-sm text-muted-foreground", className)}
104+
{...props}
105+
/>
106+
))
107+
DialogDescription.displayName = DialogPrimitive.Description.displayName
108+
109+
export {
110+
Dialog,
111+
DialogPortal,
112+
DialogOverlay,
113+
DialogTrigger,
114+
DialogClose,
115+
DialogContent,
116+
DialogHeader,
117+
DialogFooter,
118+
DialogTitle,
119+
DialogDescription,
120+
}

0 commit comments

Comments
 (0)