Skip to content

Commit

Permalink
Improve client app router configuration (#231)
Browse files Browse the repository at this point in the history
### Summary & Motivation

Improve developer experience - setting up filesystem based routing.
Configuring React Router adding things like error handling, code
splitting, dynamic path segments etc. should be as easy as creating a
file.

Inspired by NextJS and the ease of use convention based routing.

```
/app
  layout.tsx - shared layout for child routes
  page.tsx - entry point for this route segment
  error.tsx - page to show when errors occur
  not-found.tsx - page to show if resource not found
  loading.tsx - code split and load this route dynamically
  /[id]
    page.tsx - show details using the param "id"
```
*(NextJS has more functionality to work on server and client -
[docs](https://nextjs.org/docs/app/building-your-application/routing))*

### Checklist

- [x] I have added a Label to the pull-request
- [x] I have added tests, and done manual regression tests
- [x] I have updated the documentation, if necessary
  • Loading branch information
tjementum authored Nov 25, 2023
2 parents 80662f0 + 62424e4 commit c61c338
Show file tree
Hide file tree
Showing 26 changed files with 415 additions and 60 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -380,6 +380,7 @@ FodyWeavers.xsd

# Generated files
**/*.generated.d.ts
**/*.generated.ts

# Frontend builds
dist/
Binary file modified application/account-management/WebApp/bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion application/account-management/WebApp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
"version": "1.0.0",
"scripts": {
"dev": "rspack serve",
"build": "rspack build"
"build": "rspack build",
"generate:router": "./src/shared/router/generate.tsx",
"prepare": "bun run generate:router"
},
"dependencies": {
"openapi-fetch": "^0.8.1",
Expand Down
3 changes: 2 additions & 1 deletion application/account-management/WebApp/rspack.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ const outputPath = resolve(__dirname, "dist");
const configuration: Configuration = {
context: __dirname,
entry: {
main: ["./src/lib/rspack/runtime.ts", "./src/main.tsx"],
main: ["./src/shared/rspack/runtime.ts", "./src/main.tsx"],
},
output: {
clean: true,
publicPath: "auto",
path: outputPath,
filename: process.env.NODE_ENV === "production" ? "[name].[contenthash].bundle.js" : undefined,
Expand Down
27 changes: 27 additions & 0 deletions application/account-management/WebApp/src/app/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { useEffect } from "react";

type ErrorProps = {
error: Error;
reset: () => void;
};

export default function Error({ error, reset }: ErrorProps) {
useEffect(() => {
// Log the error to an error reporting service
console.error(error);
}, [error]);

return (
<div>
<h2>Something went wrong!</h2>
<button
onClick={
// Attempt to recover by trying to re-render the segment
() => reset()
}
>
Try again
</button>
</div>
);
}
42 changes: 42 additions & 0 deletions application/account-management/WebApp/src/app/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useNavigate } from "react-router-dom";
import AcmeLogo from "@/ui/acme-logo.svg";
import { Button } from "react-aria-components";

type LayoutProps = {
children: React.ReactNode;
params: Record<string, string>;
};

export default function Root({ children, params }: LayoutProps) {
const navigate = useNavigate();

function handleCreateTenant() {
navigate("/tenant/create");
}

return (
<div className="flex flex-row h-full w-full">
<div className="flex gap-2 flex-col h-full w-80 border-r border-border bg-gray-100 px-6">
<h1 className="flex gap-1 items-center order-1 border-t border-border px-4 py-8">
<AcmeLogo className="w-6 h-6" /> ACME Company
</h1>
<div className="justify-start flex flex-row border-b border-border py-4">
<Button className="bg-blue-600 text-white py-2 px-4 rounded-full" onPress={handleCreateTenant}>
Create Tenant
</Button>
</div>
<nav className="grow">
<ul>
<li className="p-4 hover:bg-gray-200 rounded-xl cursor-pointer">
<a href={`/`}>Account Management</a>
</li>
<li className="p-4 hover:bg-gray-200 rounded-xl cursor-pointer">
<a href={`/user-management`}>User Management</a>
</li>
</ul>
</nav>
</div>
<div className="flex flex-col w-full h-full bg-background">{children}</div>
</div>
);
}
11 changes: 11 additions & 0 deletions application/account-management/WebApp/src/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Link } from "react-router-dom";

export default function NotFound() {
return (
<div>
<h2>Not Found</h2>
<p>Could not find requested resource</p>
<Link to="/">Return Home</Link>
</div>
);
}
8 changes: 8 additions & 0 deletions application/account-management/WebApp/src/app/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export default function Page() {
return (
<div>
<h2>Root</h2>
<p>This is the main page</p>
</div>
);
}
11 changes: 11 additions & 0 deletions application/account-management/WebApp/src/app/tenant/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type PageProps = {
params: Record<string, string>;
};

export default function Page({ params }: PageProps) {
return (
<div>
<h1>Show tenant id: "{params.id}"</h1>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { z } from "zod";
import { accountManagementApi } from "@/lib/api/client.ts";
import { getApiError, getFieldErrors } from "@/lib/apiErrorListSchema.ts";
import { router } from "@/router";
import { getApiError, getFieldErrors } from "@/shared/apiErrorListSchema";
import { router } from "@/lib/router/router";

export type State = {
errors?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Button, FieldError, Form, Input, Label, TextField } from "react-aria-co
import { useFormState } from "react-dom";
import { createTenant, State } from "./actions";

export function CreateTenantForm() {
export default function CreateTenantForm() {
const initialState: State = { message: null, errors: {} };
const [state, formAction] = useFormState(createTenant, initialState);

Expand All @@ -14,19 +14,19 @@ export function CreateTenantForm() {
>
<div className="flex flex-col w-fit bg-gray-200 rounded p-4 gap-2 shadow-sm">
<h1 className="text-xl font-bold">Create a tenant</h1>
<TextField name={"subdomain"} autoFocus className={"flex flex-col"} isRequired>
<TextField name="subdomain" autoFocus className="flex flex-col" isRequired>
<Label>Subdomain</Label>
<Input className="p-2 rounded-md border border-black" placeholder="subdomain" />
<FieldError />
</TextField>

<TextField name={"name"} type={"username"} className={"flex flex-col"} isRequired>
<TextField name="name" type="username" className="flex flex-col" isRequired>
<Label>Name</Label>
<Input className="p-2 rounded-md border border-black" placeholder="name" />
<FieldError />
</TextField>

<TextField name={"email"} type={"email"} className={"flex flex-col"} isRequired>
<TextField name="email" type="email" className="flex flex-col" isRequired>
<Label>Email</Label>
<Input className="p-2 rounded-md border border-black" placeholder="email" />
<FieldError />
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
type PageProps = {
params: Record<string, string>;
};

export default function LoadingPage({ params }: PageProps) {
return (
<div className="items-center flex flex-col justify-center h-full">
<div className="p-8 bg-gray-800 text-white rounded-xl shadow-md text-center gap-4 flex flex-col animate-ping"></div>
</div>
);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { useEffect, useState } from "react";
import { Button } from "react-aria-components";
import Confetti, { type ConfettiConfig } from "react-dom-confetti";

const config: ConfettiConfig = {
Expand All @@ -16,7 +15,11 @@ const config: ConfettiConfig = {
colors: ["#a864fd", "#29cdff", "#78ff44", "#ff718d", "#fdff6a"],
};

export function CreatedTenantSuccess() {
type PageProps = {
params: Record<string, string>;
};

export default function CreatedTenantSuccessPage({}: PageProps) {
const [confetti, setConfetti] = useState(false);

useEffect(() => {
Expand Down
18 changes: 0 additions & 18 deletions application/account-management/WebApp/src/error-page.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { routes } from "./router.generated";
import { createBrowserRouter } from "react-router-dom";

export const router = createBrowserRouter(routes);
2 changes: 1 addition & 1 deletion application/account-management/WebApp/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from "react";
import ReactDOM from "react-dom/client";
import { RouterProvider } from "react-router-dom";
import "./main.css";
import { router } from "./router";
import { router } from "@/lib/router/router";

ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
Expand Down
27 changes: 0 additions & 27 deletions application/account-management/WebApp/src/router.tsx

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
#!/usr/bin/env bun
// @ts-

import fs from "fs";
import path from "path";
import prettier from "prettier";
import { RoutePage, RouteType, getRouteDetails } from "./routeDetails";
import {
autoGeneratedBannerCode,
createBrowserRouterCode,
errorPageCode,
importCode,
layoutPageCode,
lazyLoadingPageCode,
normalPageCode,
} from "./pageCode";

const sourceRoot = fs.existsSync(path.join(process.cwd(), "src")) ? path.join(process.cwd(), "src") : process.cwd();
const outputPath = path.join(sourceRoot, "lib", "router", "router.generated.ts");
const appPath = path.join(sourceRoot, "app");
const importPrefix = "@";

if (!fs.existsSync(appPath)) {
throw new Error(`Could not find app directory at "${appPath}".`);
}

type TemplateObject = Record<string, string>;

function generateReactRouterCode(routeItem: RouteType): string {
if (routeItem.type === "page") {
if (routeItem.loadingPage) {
const landingRoutePage: RoutePage = { ...routeItem, page: routeItem.loadingPage };
return `{
index: true,
element: ${lazyLoadingPageCode(routeItem, landingRoutePage)},
}`;
}
return `{
index: true,
element: ${normalPageCode(routeItem)},
}`;
}
if (routeItem.type === "not-found") {
return `{
path: "*",
element: ${normalPageCode(routeItem)},
}`;
}

if (routeItem.type === "entry") {
const result: TemplateObject = {};

if (routeItem.layout != null) {
result.element = layoutPageCode(routeItem.layout);
}

if (routeItem.error != null) {
result.errorElement = errorPageCode(routeItem.error);
}

if (routeItem.children.length > 0) {
result.children = `[${routeItem.children.map((c) => generateReactRouterCode(c)).join(",\n")}]`;
}

return routeItem.aliases
.map((alias) =>
serializeTemplateObject({
path: `"${alias}"`,
...result,
})
)
.join(",\n");
}

throw new Error(`Unhandled route type "${routeItem.type}".`);
}

const route = getRouteDetails([""], { appPath, importPrefix });
const code = `${autoGeneratedBannerCode()}
${importCode()}
${createBrowserRouterCode(generateReactRouterCode(route))}
`;

try {
const routerFileContents = await prettier.format(code, {
parser: "typescript",
});
fs.writeFileSync(outputPath, routerFileContents, "utf-8");
console.log("Routes generated successfully!");
} catch (error) {
console.error(error);
console.info(code);
process.exit(1);
}

function serializeTemplateObject(object: TemplateObject) {
return Object.entries(object).reduce((result, [key, value]) => `${result}${key}: ${value},\n`, "{\n") + "}";
}
Loading

0 comments on commit c61c338

Please sign in to comment.