react-resource-view

Split & board

Master-detail, and a board grouped by a field.

Split#

The list on the left, the selected record on the right. It replaces the separate detail page rather than complementing it — which is why the resource often has no read screen to maintain at all.

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

layouts: [
  layout.split({
    // What the right-hand pane shows. The default, `update`, makes it an editor.
    action: "read",
    // Shown before anything is selected.
    emptyState: () => <p>Pick an article on the left.</p>,
    // `read/{id}` lands on `list/{id}`, so incoming links land here.
    readInList: true,
  }),
]

Pick a row on the left

NameTypeDefaultDescription
action"read" | "update""update"What the right pane renders. update makes it an editor, which is what makes a split feel like a mail client; read keeps it read-only.
emptyStateFC—Rendered before anything is selected.
readInListboolean—Makes read/{id} land on list/{id}.

Incoming links#

Links built elsewhere — a notification, an email, a share — point at the detail view. With no detail view to point at, they would land nowhere.

That is what readInList is for

It turns read/42 into list/42, so an existing link opens the split with that item selected. Set it whenever the split is the only detail your resource has.

Board#

A board: one column per value of a chosen field. It needs two things — which field groups the records, and which values are columns.

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

const STATUSES = [
  { label: "Draft", value: "draft" },
  { label: "In review", value: "review" },
  { label: "Published", value: "published" },
]

layouts: [
  layout.board({
    // Which field decides the column…
    groupBy: "status",
    // …and which columns exist, in which order.
    columns: STATUSES,
  }),
]

Grouped by status

Both options are required

layout.board() without groupBy or columns is a TypeScript error, and groupBy must name a field of the item. The layout takes its columns as a list on purpose: an empty column has to exist to be dropped into, and deriving the columns from the data would make it vanish.

columns takes the same option shape as a select field, so the field's own valueOptions can be reused directly — one list, both places.

Moving a card#

Dragging a card into another column writes the new value of groupBy through the repository, drawn in its new column before the API answers. There is no drag handler to write: moving a card is editing that field.

Opening a card#

The whole card is clickable — with a pointer cursor, a hover state and a focus ring — and a click opens the record's read view, with a way to edit it. Where it opens is not the board's to say: it is views.read.open, the same decision a row's read button follows.

views: {
  // "page" | "dialog" | "drawer". Unset, a card opens the record's page.
  read: { open: "drawer" },
  list: {
    layouts: [layout.board({ groupBy: "status", columns: STATUSES })],
  },
}
  • drawer slides it in from the right — up from the bottom on a phone.
  • dialog centres it in a dialog.
  • page, the default, goes to the record's own page, through generateLink and the configured router.

A drawer or a dialog is written into the URL as a child view of the list — …/tasks/list/tasks/read/12 — so it can be shared, a reload reopens it, and the browser's Back button closes it. The host reads it back with parseLink, as it does every other link. Escape, the close button or a click on the backdrop close it too; the focus stays inside while it is open, and goes back to the card afterwards. On the keyboard a card is reached with Tab and opened with Enter.

The controls of a card keep their click

A button, a link, a menu, a checkbox or a drag handle drawn by your row component never opens the card: a click that lands on a control — or in anything with data-card-control — is the control's alone, with no stopPropagation to add. Selecting some of the card's text does not open it either.

Moving several cards#

Cards can be selected: the checkbox that shows on hover, Ctrl/Cmd+click to add or remove one, Shift+click for a range within a column, Space on a focused card — and a long press on a phone, after which a tap adds or removes. As soon as one card is selected, a bar comes up with the count, “Move to…”, the list's own bulkActions and “Deselect all”; Escape empties the selection too. The selection is declared on views.list, not on the board, so a table, cards or a plain list of the same resource select the same way.

Dragging a selected card carries the whole selection, under a “3 cards” preview. Either way, each record is updated through the repository and drawn in its new column straight away; a card whose update fails goes back to where it was, stays selected, and a message says how many could not be moved.

views: {
  list: {
    // On by default on a board; false takes the checkboxes and the bar away.
    selectable: true,
    // Beside "Move to…", in the bar the selection brings up.
    bulkActions: [
      {
        label: "Mark as urgent",
        icon: Flame,
        onAction: (tasks, mutation) =>
          Promise.all(
            tasks.map((task) => mutation.update({ ...task, priority: "urgent" }))
          ),
      },
      "delete",
    ],
    layouts: [layout.board({ groupBy: "status", columns: STATUSES })],
  },
}
NameTypeDefaultDescription
views.read.open"page" | "dialog" | "drawer" | OpenFrame"page"Where a click on a card opens the record. Needs the read permission.
views.list.selectablebooleantrue on a boardWhether records can be selected and moved together.
views.list.bulkActions(BulkActionInterface | "delete")[]—{ label, icon?, onAction(records, mutation) }, run on the selected records; the list refreshes once it settles. "delete" is the built-in deletion of the selection.

Which one, when#

  • Split when records are read one after another and the list is a navigation aid — an inbox, a queue, a log.
  • Board when the interesting operation is moving a record between states — a pipeline, a kanban, an editorial workflow.
  • Neither when the reader is comparing records, in which case a table beats both.