react-resource-view
A form per action
One description for four screens — and a different form where creating is not editing.
The default is that every action of a resource shares one form: the columns of the list, the create form and the edit form are the same description read four times, and that is what makes a resource cheap to declare.
It is also, often enough, a lie. Creating an article is not editing one — half the fields do not exist yet, and a couple of them the reader is never allowed to touch again. This page is about saying so.
One description, four screens#
The root form is the description every action starts from. Declared alone, it drives the lot:
createViewResource("articles", {
path: "/api/articles",
// Read by the list's columns, the create form, the edit form and the
// detail page — all four at once.
form: {
inputs: {
title: { label: "Title", required: true },
author: { label: "Author" },
status: { label: "Status", controller: SelectInputController, valueOptions: STATUSES },
},
},
})| Name | Type | Default | Description |
|---|---|---|---|
form | FormDefinition | — | The shared description. The table's columns, and the form every action falls back to. |
views.create.form | FormDefinition | — | Only the creation form, merged over the shared one. |
views.update.form | FormDefinition | — | Only the edit form — the one a row's edit button opens. |
views.read.form | FormDefinition | — | The detail page. Rendered with the form's action forced to read, so the fields display rather than accept input. |
views.list.form | FormDefinition | — | The columns, when the table should show something other than what the forms ask for. A layout can narrow it further still, with fields. |
When creating is not editing#
A per-action declaration is the shared form plus what differs. Below, the create form asks for two fields and the edit form for six — the article is started in a sentence and completed later.
createViewResource("articles", {
path: "/api/articles",
// The columns, and the edit form: everything an article has.
form: articleForm,
views: {
// Creating asks for what a draft cannot exist without. The rest is
// filled in afterwards, from the edit form. The create form is merged
// over the shared one, so it only says what it takes away.
create: {
form: {
inputs: {
author: false,
status: false,
readingTime: false,
publishedAt: false,
},
},
},
},
})Create asks for two fields, edit for six — both in a drawer
Add an article and then edit it: the panel that opens is not the panel that opened a moment ago. Nothing else in the declaration changed — the columns, the filters and the repository are still read from the same place.
Each action names itself
An action that declares no title takes the resource's name, prefixed: Create — Articles, Edit — Articles, Delete — Articles. That name is what a dialog and a drawer are titled with, so an overlay always says which record it is about. views.create.title overrides it, and views.create.buttonLabel renames the button rather than the screen.
A form merges, it does not replace#
The one to know before writing an override
views.<action> is merged over the root of the declaration, deeply. So views.create.form only says what differs: an input it names is adjusted, an input it sets to false is gone, and every other field is inherited as it is.
Which keeps the override next to the shared description without the two drifting — the label, the controller and the validation of a field stay written once:
- adjust a field —
title: { required: false }— and the rest of its description is kept; - remove one —
publishedAt: false— when the action has no use for it; - add one the shared form does not have, with its full description.
Arrays and functions are replaced rather than merged: a valueOptions declared on an action is the whole list.
A resource that declares neither a shared form nor one for the action falls back to the defaultForm of the form configuration — the application-wide labels and components, and no fields of its own.
The read shape and the write shape#
Asymmetry is rarely only about which fields are shown. An API that returns an embedded author and accepts an IRI is returning one type and accepting another, and createViewResource takes both:
interface Article { // what the API returns
"@id": string
title: string
author: { "@id": string; name: string }
publishedAt: string
}
interface ArticleWrite { // what the API accepts
title: string
author: string // an IRI, not the embedded record
publish: boolean // no column shows it, no read returns it
}
// <Item, Write> — the second one is the write shape.
createViewResource<Article, ArticleWrite>("articles", {
path: "/api/articles",
form: writeForm, // typed against ArticleWrite
})| Name | Type | Default | Description |
|---|---|---|---|
Itemrequired | extends BaseJsonLdItemInterface | — | What a read returns. Types the detail view, the rows, and every field option — titleField, layout.board({ groupBy })… |
Write | object | Item | What a write accepts. Types the forms, beforeCreate and beforeUpdate. |
The second defaults to the first, so a resource whose API round-trips the same shape names one type and stops there.
Fields the reader never fills#
Not every difference between creating and editing is a field being removed. Two field keys and two hooks cover the rest:
views: {
create: {
form: {
inputs: {
// Never asked for, never validated, never a column — supplied below.
workspace: { generatedValue: true },
},
},
},
update: {
form: {
inputs: {
// Shown, but the reader cannot move it.
slug: { readonly: true },
},
},
},
},
hooks: {
// The value the create form deliberately does not ask for.
beforeCreate: (data, { viewResourceContext }) => ({
...data,
workspace: viewResourceContext?.filter?.workspace,
}),
},| Name | Type | Default | Description |
|---|---|---|---|
generatedValue | boolean | — | The field exists but nobody types it: excluded from validation and from the table's columns. Pair it with hooks.beforeCreate. |
readonly | boolean | — | Shown, and changes to it dropped. An identifier the API owns after the first write. |
hooks.beforeCreate | (data, context) => data | — | Runs on the payload of a create only. The tenant, the owner, the parent of a sub-view. |
hooks.beforeUpdate | (data, context) => data | — | Runs on the payload of an update only. |
Both hooks receive the surrounding view context, which is how a record created from a tab arrives already attached to the record whose tab it was created from — without a hidden field carrying the parent's id through the form.
Reading and deleting#
The other two actions are forms as well, and differ from the shared one more often than they look.
views: {
// A detail page carrying more than the list does.
read: { form: detailForm },
// The confirmation is a form too — usually an empty one.
delete: { buttonLabel: "Delete this article" },
}views.readis the one place a resource can afford to show everything — a row has a width, a detail page does not. It is also where tabs hang.views.deleteis the one action that starts as a dialog rather than a page: a confirmation that replaced the page would take away the list it is asking about.
Next
The forms of a resource are ordinary react-data-form descriptions, so everything that library documents applies here — groups, multi-step forms and the mapping of a 422 back onto fields. And the dialect is what decides how a validation failure was spelled in the first place.