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
| Name | Type | Default | Description |
|---|---|---|---|
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. |
emptyState | FC | — | Rendered before anything is selected. |
readInList | boolean | — | 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 })],
},
}drawerslides it in from the right — up from the bottom on a phone.dialogcentres it in a dialog.page, the default, goes to the record's own page, throughgenerateLinkand 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 })],
},
}| Name | Type | Default | Description |
|---|---|---|---|
views.read.open | "page" | "dialog" | "drawer" | OpenFrame | "page" | Where a click on a card opens the record. Needs the read permission. |
views.list.selectable | boolean | true on a board | Whether 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.