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>
169 lines
7.2 KiB
Markdown
169 lines
7.2 KiB
Markdown
# 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.
|