Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Contributing

## Development Overview

- The repository uses [vite](vite.dev) as a build system.
- [Radix-UI](https://www.radix-ui.com/) is the component library used.
- [Tailwind is used for styling](https://tailwindcss.com/)
4 changes: 3 additions & 1 deletion src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ResourceSchema } from "./state/openapi";
import { RouteErrorBoundary } from "./components/error_boundary";
import SpecSpecifierPage from "./app/spec_specifier/page";
import { createBrowserRouter, RouteObject, RouterProvider } from "react-router-dom";
import Layout from "./app/explorer/page";
Expand All @@ -20,6 +21,7 @@ function createRoutes(resources: ResourceSchema[]): RouteObject[] {
const routes = [{
path: "/",
element: <Layout />,
errorElement: <RouteErrorBoundary />,
children: [
{
path: "/",
Expand Down Expand Up @@ -63,7 +65,7 @@ function App() {
function Routes() {
const resources = useAppSelector(selectResources);
return (
<RouterProvider router={createBrowserRouter(createRoutes(resources))} />
<RouterProvider router={createBrowserRouter(createRoutes(resources))} />
)
}

Expand Down
22 changes: 11 additions & 11 deletions src/app/explorer/info.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ type InfoPageProps = {
export default function InfoPage(props: InfoPageProps) {
const params = useParams();
const [state, setState] = useState<ResourceInstance | null>(null);
const childResources = useAppSelector((globalState) =>

const childResources = useAppSelector((globalState) =>
state ? selectChildResources(globalState, props.resource, (state.properties as ResourceProperties)?.path?.split('/').pop() || '') : []
);

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

const properties = useMemo(() => {
if(state?.properties) {
const results = [];
for (const [key, value] of Object.entries(state.properties as ResourceProperties)) {
results.push(<p key={key}>
<b>{key}:</b> {value}
</p>)
}
return results;
if (state?.properties) {
const results = [];
for (const [key, value] of Object.entries(state.properties as ResourceProperties)) {
results.push(<p key={key}>
<b>{key}:</b> {value}
</p>)
}
return <Spinner />
return results;
}
return <Spinner />
}, [state]);

const customMethods = useMemo(() => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/explorer/resource_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export default function ResourceListPage(props: ResourceListProps) {
</Button>
</div>
</div>
<ResourceListTable
<ResourceListTable
resource={props.resource}
resources={state.resources}
onRefresh={refreshList}
Expand Down
99 changes: 99 additions & 0 deletions src/components/error_boundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { Component, ErrorInfo, ReactNode } from "react";
import { useRouteError, useNavigate } from "react-router-dom";
import { findErrorHandler } from "@/lib/error_handlers";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";

interface ErrorDisplayProps {
error: Error | unknown;
reset: () => void;
}

export function ErrorDisplay({ error, reset }: ErrorDisplayProps) {
const navigate = useNavigate();
const errorMessage = (error as Error)?.message || (typeof error === 'string' ? error : "Something went wrong.");
let title = "An error occurred";
let description = errorMessage;
let action = <Button onClick={reset}>Go Home</Button>;

const handler = findErrorHandler(error);
if (handler) {
title = handler.title(error);
description = handler.description(error);
const actionContent = handler.action(error, { error, reset, navigate });
if (actionContent) {
action = <>{actionContent}</>;
}
}

return (
<Dialog open={true} onOpenChange={() => reset()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>
{description}
</DialogDescription>
</DialogHeader>
<DialogFooter>
{action}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

interface Props {
children: ReactNode;
}

interface State {
hasError: boolean;
error: Error | null;
}

export class ErrorBoundary extends Component<Props, State> {
public state: State = {
hasError: false,
error: null,
};

public static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}

public componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error("Uncaught error:", error, errorInfo);
}

private handleClose = () => {
this.setState({ hasError: false, error: null });
window.location.href = "/";
};

public render() {
if (this.state.hasError) {
return <ErrorDisplay error={this.state.error} reset={this.handleClose} />;
}

return this.props.children;
}
}

export function RouteErrorBoundary() {
const error = useRouteError();
const navigate = useNavigate();

const handleClose = () => {
navigate("/");
};

return <ErrorDisplay error={error} reset={handleClose} />;
}
25 changes: 14 additions & 11 deletions src/components/resource_list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { ResourceInstance } from "@/state/fetch";
import { useNavigate } from "react-router-dom";
import { toast } from "@/hooks/use-toast";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { ErrorBoundary } from "@/components/error_boundary";

type ResourceListTableProps = {
resource: ResourceSchema;
Expand All @@ -25,7 +26,7 @@ type ColumnDef = {

export function ResourceListTable({ resource, resources, onRefresh }: ResourceListTableProps) {
const navigate = useNavigate();

const dropDownMenuColumn: ColumnDef = {
id: "actions",
accessorKey: "actions",
Expand Down Expand Up @@ -65,19 +66,19 @@ export function ResourceListTable({ resource, resources, onRefresh }: ResourceLi
};

function createColumns(r: ResourceSchema | undefined) {
if(r) {
if (r) {
const properties = r.properties();
const columns: ColumnDef[] = properties
.sort((a, b) => {
// First sort by priority (path and id first)
const priorityOrder = { path: 0, id: 1 };
const aPriority = priorityOrder[a.name as keyof typeof priorityOrder] ?? 2;
const bPriority = priorityOrder[b.name as keyof typeof priorityOrder] ?? 2;

if (aPriority !== bPriority) {
return aPriority - bPriority;
}

// Then sort alphabetically
return a.name.localeCompare(b.name);
})
Expand All @@ -101,10 +102,10 @@ export function ResourceListTable({ resource, resources, onRefresh }: ResourceLi
async function deleteResource(r: ResourceInstance) {
try {
await r.delete();
toast({description: `Deleted ${r.path}`});
toast({ description: `Deleted ${r.path}` });
onRefresh();
} catch (error) {
toast({description: `Failed to delete resource: ${error instanceof Error ? error.message : String(error)}`});
toast({ description: `Failed to delete resource: ${error instanceof Error ? error.message : String(error)}` });
}
}

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

return (
<DataTable
columns={createColumns(resource)}
data={resources}
/>
<ErrorBoundary>
<DataTable
columns={createColumns(resource)}
data={resources}
/>
</ErrorBoundary>
);
}
}
4 changes: 2 additions & 2 deletions src/components/settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export function Settings() {
);
dispatch(setSchema(new OpenAPI(apiClient)));
} catch (error) {
toast({description: `Failed to auto-load OpenAPI document: ${error}`})
toast({ description: `Failed to auto-load OpenAPI document: ${error}` })
}
}
};
Expand All @@ -86,7 +86,7 @@ export function Settings() {
// Persist the spec config
dispatch(setSpecConfig({ url: state.url, prefix: state.prefix || "" }));
} catch (error) {
toast({description: `Failed to fetch OpenAPI document: ${error}`})
toast({ description: `Failed to fetch OpenAPI document: ${error}` })
}
};

Expand Down
120 changes: 120 additions & 0 deletions src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"

import { cn } from "@/lib/utils"

const Dialog = DialogPrimitive.Root

const DialogTrigger = DialogPrimitive.Trigger

const DialogPortal = DialogPrimitive.Portal

const DialogClose = DialogPrimitive.Close

const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"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",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"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",

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is it worth switching to radix-themes instead of tailwind? Then we can remove some of these customs styles: https://www.radix-ui.com/blog/themes-3

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe? We're not technically using Radix, but using shadcn/ui, which is a thin layer on top of Radix. Not sure how those interact.

className
)}
{...props}
>
{children}
<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">
<Cross2Icon className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName

const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"

const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"

const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName

const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName

export {
Dialog,
DialogPortal,
DialogOverlay,
DialogTrigger,
DialogClose,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
Loading
Loading