react-resource-view

Sub-views & tabs

Nesting a resource inside another one's detail page.

The shape of the problem#

An author's page is not only the author. It is the author, their articles, their comments, their activity — each of which is a collection in its own right, and each of which should be filtered by the record on screen without that filter being the reader's to remove.

A sub-view is a full view context nested inside another: it fetches, it filters, it paginates, it writes. It is not a read-only panel.

The example, live

The playground's back office has a company page built exactly this way: the form first, then its team, filtered by the company on screen and creating records that already belong to it. Open a company with its tabs.

Declaring tabs#

tabs.items is the tabs of a detail page — on views.read, or on views.update under the edit form. An entry takes one of two shapes — a nested resource, or a component of your own.

views: {
  read: {
    tabs: {
      items: [
        {
          // A nested resource, filtered by the record on screen.
          resource: articles,
          label: "Articles",
          icon: FileText,
          filter: ({ item }) => ({ author: item["@id"] }),
        },
        {
          // Or a tab of your own, with no resource behind it.
          id: "activity",
          label: "Activity",
          icon: Activity,
          component: AuthorActivity,
        },
      ],
    },
  },
}
NameTypeDefaultDescription
resourceViewResource | string—The nested resource, or its identifier. Omit for a free-form tab.
actionAction"list"Which of its views the tab renders.
idstring—The tab's identifier in the URL. Defaults to the resource's @id.
labelstring—The tab label. Defaults to the resource's name.
iconIconType—The tab icon. Defaults to the resource's.
componentFC—Custom rendering, in place of a resource — the free-form tab.
filterFilterInterface | (({ item, parent }) => FilterInterface)—Narrows the tab to the record on screen — static, or derived from it.
defaultDataobject | (({ item, parent }) => object)—What a record created from the tab starts with.

Beside items, tabs.aside takes a component drawn beside the tabs on a desktop and above them on a phone — a summary of the record, say.

MultiViewTab renders the tab bar. It is exported, so a detail page laid out by hand can place it wherever it belongs.

By default the tabs are a bar above the sub-view. It stays on a single line and scrolls sideways when they run past the screen, which is what a phone needs: eight sub-views used to wrap onto three rows of buttons and push the sub-view itself below the fold, and the labels were cut short to limit the damage.

tabs: {
  // "horizontal" by default: a bar above the sub-view.
  orientation: "vertical",
  items: [...],
}

orientation: "vertical" puts them in a column beside the sub-view instead — the shape a record with a dozen of them wants, since a menu read from top to bottom shows them all at once and the sub-view keeps the rest of the width. The column follows the reader down the page.

The column is a desktop shape

Below 768px a menu beside the sub-view would leave it a third of the screen, so vertical falls back to the scrolling bar there. Nothing to declare for it: the same declaration reads both ways.

Deriving from the parent#

filter and defaultData may be functions of { item, parent } — the record on screen and its view context — typed after the resource the tabs are declared on. They run when the sub-view mounts.

{
  resource: articles,
  // Filters the list…
  filter: ({ item }) => ({ author: item["@id"] }),
  // …and pre-fills the create form opened from inside it.
  defaultData: ({ item }) => ({ author: item["@id"] }),
}

Filter and defaultData together

Filtering alone gives a list of the author's articles whose create button makes an article belonging to nobody. Setting defaultData as well is what makes “new article” mean “new article by this author”.

Filters injected this way are marked as generated, so “clear search” keeps them — as the filters page describes. Losing that one would show every author's articles inside a page about one of them.

Nesting in the URL#

The open tab is a segment of the context, and a child view appends its own three segments after it. So a nested state is addressable, and the back button walks out of it a step at a time.

/admin/authors/read/7/articles
//                            └ subResource: which tab is open

/admin/authors/read/7/articles/categories/create
//                            └ the child view's own three segments
  • Switching tabs does not reset the scroll position — the views ask the router not to.
  • A sub-view's filter is not written to the URL: only the outermost context owns the query string, which is what keeps two nested lists from overwriting each other.

A child view in a dialog#

Sometimes the nested thing should not replace the page. A category has to be created in the middle of writing an article, and the article must still be there afterwards.

import { ChildViewResourceDialog } from "react-resource-view"

// A second context, rendered over the first — creating a category without
// leaving the article being written.
<ChildViewResourceDialog
  childViewResource={{ resource: categories, resourceAction: ActionList.create }}
/>

ChildViewResourceDialog renders a second context over the first. The parent is untouched, and the resource's onChange is what tells the field behind the dialog to reload its options.

views.<action>.open

A view can also ask to open over the page rather than in place — with open: "dialog" for a dialog, or open: "drawer" for a panel sliding in from the right, from the bottom on a phone — together with afterSave, which decides what happens once the write lands.