react-resource-view
Scopes & menu
Grouping resources per area of the application, and building its menu.
What a scope is#
An area of the application: its set of resources, its menu, its landing page, and whether the reader is allowed in it at all. The scope name is the first segment of every URL the views build, which is what makes it a genuine boundary rather than a folder.
- A namespace. Two resources may share an id if they live in different scopes.
- An authorisation boundary. One function decides access to everything inside.
- A code-splitting boundary. Scopes are loaded lazily, so an administration area is never downloaded by a reader who never opens it.
- A layout boundary.
decoratorComponentwraps every view of the scope.
You may not need one
Scopes earn their keep when an application has clearly separate areas — a back office and a customer portal. Below that, pass resources directly and skip the whole mechanism.
// A small application needs no scopes at all.
<ResourceViewProvider
viewResourceContextParams={{ resourceId: "articles", resourceAction: ActionList.list }}
configuration={{ resources: [articles, authors] }}
/>Declaring one#
import type { ScopeInterface } from "react-resource-view"
import { UnauthorizedError } from "react-resource-view"
export const adminScope: ScopeInterface = {
name: "admin",
label: "Administration",
resources: [articles, authors, categories],
home: "/admin/articles/list",
menu: [
createItemMenuWithResource({ resource: articles }),
createItemMenuWithResource({ resource: authors }),
{ name: "Reports", href: "/admin/reports", icon: BarChart },
],
authorization: () => {
if (!isLogged()) throw new UnauthorizedError()
return true
},
decoratorComponent: AdminShell,
}| Name | Type | Default | Description |
|---|---|---|---|
namerequired | string | — | The first URL segment, and the value resources refer to. |
label | string | — | Human-readable name, for a scope switcher. |
resources | ViewResourceInterface[] | — | Everything the scope can render. |
menu | MenuItemInterface[] | — | The navigation, yours to render. |
home | string | — | Where an entry with no context lands. |
authorization | () => boolean | — | Throws UnauthorizedError (401) or ForbiddenError (403) to refuse. |
decoratorComponent | FC<{ children }> | — | Wraps every view of the scope — the shell of the area. |
middleWare | () => void | — | Runs when the scope is entered — analytics, a fetch, a redirect. |
defaultViewResourceContextParams | ViewResourceContextParams | — | What the scope opens on when the URL says nothing more. |
Wiring them up#
import { ResourceViewProvider } from "react-resource-view"
<ResourceViewProvider
viewResourceContextParams={parseLink(url)}
configuration={{
// Lazily loaded, one per area — each import() is its own chunk.
scopes: {
admin: () => import("./scopes/admin").then((m) => m.adminScope),
portal: () => import("./scopes/portal").then((m) => m.portalScope),
},
defaultScope: "portal",
onUnauthorized: () => router.navigate({ to: "/sign-in" }),
scopeFallback: <Loader />,
}}
/>| Name | Type | Default | Description |
|---|---|---|---|
scopes | Record<string, () => Promise<ScopeInterface>> | — | Lazily loaded. The function is the split point, so each area is its own chunk. |
resources | ViewResourceInterface[] | — | The flat alternative, when there are no scopes. |
defaultScope | string | — | Used when the URL names none. |
defaultResource | Partial<ViewResourceInterface> | — | Defaults every resource starts from — a shared row component, a shared empty state. |
decoratorComponent | FC<{ children }> | — | Wraps every view, across every scope. |
onUnauthorized | () => void | — | Called when an authorisation throws. Where you redirect to sign-in. |
scopeFallback | ReactNode | — | Shown while a scope's chunk is loading. |
Lazy scopes suspend
A lazily loaded scope suspends on first paint. That is the right trade-off in an application, and the wrong one for an example sitting in a page of prose — which is why the demos below pass resources directly, while the playground, being a real application, declares two lazily loaded scopes: a back office and the demos' own.
Building the menu#
The menu is data, and rendering it is yours — the package has no sidebar component. createItemMenuWithResource builds an entry pointing at a resource's list, in the configured routing mode.
import { createItemMenuWithResource, useIsActiveItemMenu } from "react-resource-view"
function Sidebar({ menu }: { menu: MenuItemInterface[] }) {
const isActive = useIsActiveItemMenu()
return menu.map((item) => (
<Link key={item.name} to={item.href} aria-current={isActive(item) ? "page" : undefined}>
{item.icon && <item.icon />}
{item.name}
</Link>
))
}useIsActiveItemMenu, not isActiveItemMenu
isActiveItemMenu reads the address bar directly, so on a server it reports every entry as inactive and the browser then disagrees with the markup. useIsActiveItemMenu asks the router instead and answers the same on both sides. It returns a predicate rather than a boolean, because a hook cannot be called in a loop.
| Name | Type | Default | Description |
|---|---|---|---|
namerequired | string | — | The label. |
href | string | — | Where it goes. Built, not written. |
icon | IconType | — | Shown beside the label. |
items | MenuItemInterface[] | — | Children, for a nested menu. |
priority | number | — | Ordering, when entries come from several places. |
hidden | boolean | — | Keeps the entry out without removing it. |
subNavigation | boolean | — | Renders a sub-navigation bar for the entry's children. |
locked | () => boolean | — | Shows a padlock and redirects — for a feature behind a plan. |
component | FC<{ menuItem }> | — | Renders this entry your own way. |
One endpoint, two scopes#
The same API collection often needs two different treatments. Declare it twice, once per scope, with different permissions, filters and layouts:
// Two resources over one endpoint, one per scope.
createViewResource("articles", { scope: "admin", path: "/api/articles", canDelete: true, … })
createViewResource("articles", { scope: "portal", path: "/api/articles", canDelete: false, … })Link building prefers the resource matching the current scope, so generateLinkFromUri keeps a reader inside the area they are already in. And permissions are per declaration, which is what makes the portal copy genuinely read-only in the interface.