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. decoratorComponent wraps 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#

scopes/admin.ts
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,
}
NameTypeDefaultDescription
namerequiredstringThe first URL segment, and the value resources refer to.
labelstringHuman-readable name, for a scope switcher.
resourcesViewResourceInterface[]Everything the scope can render.
menuMenuItemInterface[]The navigation, yours to render.
homestringWhere an entry with no context lands.
authorization() => booleanThrows UnauthorizedError (401) or ForbiddenError (403) to refuse.
decoratorComponentFC<{ children }>Wraps every view of the scope — the shell of the area.
middleWare() => voidRuns when the scope is entered — analytics, a fetch, a redirect.
defaultViewResourceContextParamsViewResourceContextParamsWhat 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 />,
  }}
/>
NameTypeDefaultDescription
scopesRecord<string, () => Promise<ScopeInterface>>Lazily loaded. The function is the split point, so each area is its own chunk.
resourcesViewResourceInterface[]The flat alternative, when there are no scopes.
defaultScopestringUsed when the URL names none.
defaultResourcePartial<ViewResourceInterface>Defaults every resource starts from — a shared row component, a shared empty state.
decoratorComponentFC<{ children }>Wraps every view, across every scope.
onUnauthorized() => voidCalled when an authorisation throws. Where you redirect to sign-in.
scopeFallbackReactNodeShown 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.

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.

NameTypeDefaultDescription
namerequiredstringThe label.
hrefstringWhere it goes. Built, not written.
iconIconTypeShown beside the label.
itemsMenuItemInterface[]Children, for a nested menu.
prioritynumberOrdering, when entries come from several places.
hiddenbooleanKeeps the entry out without removing it.
subNavigationbooleanRenders a sub-navigation bar for the entry's children.
locked() => booleanShows a padlock and redirects — for a feature behind a plan.
componentFC<{ 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.