Fill frontend rule gaps and broaden v2 tokens
Add the frontend guides the v2 UI kit and compliance-portal need but that the first rework left uncovered: forms, routing, client state, and permission-gated UI. forms.md documents a tiered approach on Base UI Field/Form -- native constraints, then a validate function, then zod parsed in onSubmit, and react-hook-form only for large or dynamic forms -- and drops the custom useFormWithSchema wrapper. routing.md covers @probo/routes, navigation, typed params, URL-as-state, redirects, auth/protected routes, and the folded-in no-outlet-context rule. state-management.md gives a decision order across Relay, URL, local state, context, and zustand. permissions.md gates UI on the canUpdate/canDelete permission(action:) fields without re-encoding authorization in the client. Rename v2-colors.md to v2-tokens.md and add the typography, radius, shadow, and native-spacing scales alongside color. Extend ui.md with user feedback, empty-state, and accessibility sections; standardize toasts on Base UI's Toast (Toast.useToastManager) and retire the legacy useToast across ui.md, forms.md, error-handling.md, and relay.md. Add an Intl formatting section to i18n.md and a non-Relay HTTP / file upload-download section to ts-style.md. Update the AGENTS.md index and the v2-color-scale cursor rule for the new and renamed guides. Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
168
contrib/claude/routing.md
Normal file
168
contrib/claude/routing.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Routing, navigation, and auth
|
||||
|
||||
Probo frontends route with [React Router](https://reactrouter.com/) (`react-router` v8), wrapped by the **`@probo/routes`** helpers and lazy-loaded with **`@probo/react-lazy`**. This guide covers how routes are declared, how to navigate and read params, how to use the URL as state, and how authenticated/protected routes are composed. Folder placement of route files is covered in [`app-arborescence.md`](app-arborescence.md); this guide is about the routing API itself.
|
||||
|
||||
## Related guides
|
||||
|
||||
| Topic | Guide |
|
||||
|-------|--------|
|
||||
| Where `routes.ts` lives, route-segment folders | [`contrib/claude/app-arborescence.md`](app-arborescence.md) |
|
||||
| Loaders, `queryRef`, preloading | [`contrib/claude/relay.md`](relay.md) |
|
||||
| Route error boundaries | [`contrib/claude/error-handling.md`](error-handling.md) |
|
||||
| Permission-gated UI within a route | [`contrib/claude/permissions.md`](permissions.md) |
|
||||
|
||||
## `AppRoute` and the route tree
|
||||
|
||||
Routes are declared as `AppRoute[]` and converted with `routeFromAppRoute` before being handed to `createBrowserRouter`. `AppRoute` extends React Router's `RouteObject` with a `Fallback` component; `routeFromAppRoute` wraps the `Component` in a `Suspense` boundary using that `Fallback` automatically.
|
||||
|
||||
```ts
|
||||
// routes.ts — one per resource folder (see app-arborescence.md)
|
||||
import { lazy } from "@probo/react-lazy";
|
||||
import { type AppRoute } from "@probo/routes";
|
||||
|
||||
import { MeasuresPageSkeleton } from "./MeasuresPageSkeleton";
|
||||
|
||||
export const measureRoutes = [
|
||||
{
|
||||
path: "measures",
|
||||
Fallback: MeasuresPageSkeleton,
|
||||
Component: lazy(() => import("./MeasuresPageLoader")),
|
||||
},
|
||||
{
|
||||
path: "measures/:measureId",
|
||||
Component: lazy(() => import("./MeasureDetailLayoutLoader")),
|
||||
children: [
|
||||
{ path: "overview", Component: lazy(() => import("./overview/MeasureOverviewPage")) },
|
||||
],
|
||||
},
|
||||
] satisfies AppRoute[];
|
||||
```
|
||||
|
||||
The app root spreads each resource's routes and maps them once:
|
||||
|
||||
```tsx
|
||||
import { routeFromAppRoute } from "@probo/routes";
|
||||
import { measureRoutes } from "./pages/organizations/measures/routes";
|
||||
|
||||
const routes = [
|
||||
{ path: "/", Component: lazy(() => import("./pages/MainLayout")), children: [...measureRoutes] },
|
||||
] satisfies AppRoute[];
|
||||
|
||||
export const router = createBrowserRouter(routes.map(routeFromAppRoute));
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Routes declare only `path`, `Fallback`, `Component` (a lazy loader), `ErrorBoundary`, and `children` — **no Relay logic in the route object** (that lives in the `*Loader`; see [`relay.md`](relay.md)).
|
||||
- Use `lazy()` from `@probo/react-lazy` for the `Component` so every page is code-split.
|
||||
- `Fallback` is the route-level skeleton; reuse the page's `*Skeleton`.
|
||||
|
||||
## Navigation
|
||||
|
||||
Navigate **declaratively** with `Link` for anything the user clicks, and **imperatively** with `useNavigate` only after an effect (e.g. post-submit redirect).
|
||||
|
||||
```tsx
|
||||
import { Link, useNavigate } from "react-router";
|
||||
|
||||
// Declarative — preferred
|
||||
<Link to={`measures/${measureId}`}>{t("measures.view")}</Link>
|
||||
|
||||
// Imperative — only when navigation follows an action
|
||||
const navigate = useNavigate();
|
||||
navigate(`measures/${newId}`);
|
||||
```
|
||||
|
||||
Build paths from segments; never hand-concatenate query strings (see [`ts-style.md`](ts-style.md) — use `URL` / `URLSearchParams`).
|
||||
|
||||
## Route params
|
||||
|
||||
Read params with `useParams` **inside the component that needs them** — do not drill them as props from a parent that only read the URL to pass them down (see [`react-components.md`](react-components.md#props-are-for-configuration-and-composition-not-data)). Params are always `string | undefined`; narrow before use.
|
||||
|
||||
```tsx
|
||||
const { measureId } = useParams<{ measureId: string }>();
|
||||
if (measureId == null) {
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
Prefer a small dedicated hook (`useOrganizationId()`-style) when the same param is read across many components.
|
||||
|
||||
## URL as state (search params)
|
||||
|
||||
State that should survive reload, be shareable, or be linkable — the active tab, a filter, a sort, a search term, pagination cursors — belongs in the **URL**, not `useState`. Use `useSearchParams`.
|
||||
|
||||
```tsx
|
||||
import { useSearchParams } from "react-router";
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const status = searchParams.get("status") ?? "OPEN";
|
||||
|
||||
function onStatusChange(next: string) {
|
||||
setSearchParams((prev) => {
|
||||
prev.set("status", next);
|
||||
return prev;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
See [`state-management.md`](state-management.md) for when to choose the URL over local/global state.
|
||||
|
||||
## Redirects
|
||||
|
||||
Redirect from a route `loader` (throwing `redirect`) for canonical/index redirects, and with `<Navigate>` for render-time redirects (e.g. role-based landing).
|
||||
|
||||
```tsx
|
||||
// Index redirect from a loader
|
||||
{ index: true, loader: () => { throw redirect("general"); } }
|
||||
|
||||
// Render-time redirect
|
||||
<Navigate to="login" replace />
|
||||
```
|
||||
|
||||
## Auth and protected routes
|
||||
|
||||
Authentication state lives in a **provider** near the root (a viewer / current-user context), not in route objects. Protected subtrees are composed by nesting routes under a layout/provider that loads the viewer; unauthenticated access is handled by the **route error boundary**, which redirects to login.
|
||||
|
||||
```tsx
|
||||
// RootErrorBoundary — redirect to login on an auth error, render the error page otherwise
|
||||
export function RootErrorBoundary() {
|
||||
const error = useRouteError();
|
||||
if (error instanceof UnAuthenticatedError) {
|
||||
const search = new URLSearchParams({ continue: window.location.href });
|
||||
return <Navigate to={{ pathname: "/auth/login", search: `?${search}` }} />;
|
||||
}
|
||||
return <PageError error={error instanceof Error ? error : new Error("unknown error")} />;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// Role-based landing — redirect at render time based on the viewer's role
|
||||
function OrganizationIndex() {
|
||||
const { role } = use(CurrentUser);
|
||||
switch (role) {
|
||||
case Role.EMPLOYEE: return <Navigate to="employee" />;
|
||||
case Role.AUDITOR: return <Navigate to="measures" />;
|
||||
default: return <Navigate to="tasks" />;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Rules:
|
||||
- The **server** authorizes every request; the client redirects on `UnAuthenticatedError` purely for UX.
|
||||
- Per-action gating inside an authenticated page uses `permission(action:)` booleans (see [`permissions.md`](permissions.md)), not role checks.
|
||||
- Attach `ErrorBoundary` at the boundary you want auth failures to bubble to (root for whole-app, a section boundary for embedded widgets — see [`error-handling.md`](error-handling.md)).
|
||||
|
||||
## No domain data through `Outlet` context
|
||||
|
||||
A layout **must not** pass fetched domain data to child routes via `useOutletContext`. Each child page that needs data follows the Loader + Page pattern with its **own** query — the same as a sibling page. This keeps every page independently loadable and avoids hidden coupling to the parent's query.
|
||||
|
||||
```tsx
|
||||
// Bad — layout fetches and forwards domain data through Outlet context
|
||||
<Outlet context={{ showBranding: banner.showBranding }} />;
|
||||
const { showBranding } = useOutletContext<{ showBranding: boolean }>();
|
||||
|
||||
// Good — the child page owns its loader + query (see relay.md)
|
||||
const [queryRef, loadQuery] = useQueryLoader(snippetPageQuery);
|
||||
useEffect(() => { loadQuery({ cookieBannerId }); }, [loadQuery, cookieBannerId]);
|
||||
```
|
||||
|
||||
`Outlet` context is fine for **non-domain** UI coordination (a layout-owned callback, a `ref`), never for loaded entities or URL ids a child can read itself.
|
||||
Reference in New Issue
Block a user