The form
One .form() call lays out the create and edit screens. Sections, tabs and a sidebar arrange the fields, and any field can hide, lock or compute itself from a handler that runs on the server.
What goes in it
.form() takes three keys. fields is required.
| Key | Holds |
|---|---|
fields | The main column, as a list of layout items |
sidebar | A fixed column beside it, with its own list |
view | The renderer. collection-form, or global-form on a global |
.form(({ v, f }) =>
v.collectionForm({
sidebar: { position: "right", fields: [f.status, f.slug] },
fields: [
{ type: "section", label: "Content", fields: [f.title, f.body] },
],
}),
)A field you leave out is gone
.form() is the whole form. Nothing is appended for the fields you did not
name, and no picker puts them back. Add a field to the collection later and it
will not appear until you add it here too.
With no .form() at all, every field renders in the order you declared it.
The four layout items
A fields array holds any mix of these.
| Item | Renders |
|---|---|
f.title | That field, plainly |
{ field: f.title, … } | That field, with per-instance config |
{ type: "section", … } | A titled group of items |
{ type: "tabs", tabs: […] } | Tabbed groups of items |
Sections and tabs nest inside each other, to any depth.
Sections
{
type: "section",
label: { en: "Details", sk: "Podrobnosti" },
description: "Everything a reader sees first",
wrapper: "collapsible",
defaultCollapsed: true,
layout: "grid",
columns: 2,
fields: [f.name, f.email],
}| Key | Default | Effect |
|---|---|---|
label | none | Heading above the group |
description | none | One muted line under the heading |
wrapper | flat | collapsible puts it in an accordion |
defaultCollapsed | false | Starts a collapsible section closed |
layout | stack | inline wraps in a row, grid uses columns |
columns | 2 | Grid columns. 1 to 6 |
gap | none | Custom gap, in quarter rems |
hidden | false | Drops the whole section |
className | none | Classes on the field container |
A grid is responsive. columns: 3 means one column on a narrow form and three
on a wide one. Nobody gets three squeezed inputs on a laptop.
Tabs
{
type: "tabs",
tabs: [
{ id: "basic", label: "Basic", fields: [f.name, f.email] },
{
id: "security",
label: "Security",
icon: { type: "icon", props: { name: "ph:lock" } },
fields: [{ type: "section", label: "Access", fields: [f.banned] }],
},
],
}Each tab needs id and label. icon and hidden are optional, and fields
takes the same four layout items as anywhere else.
.form() hands you v and f, not c. So an icon on a tab is written out by
hand, the way the example does it.
The sidebar
sidebar: { position: "right", fields: [f.status, f.publishedAt] }position is right by default, and left mirrors it. The sidebar sticks
while the main column scrolls, and it drops above the form on a narrow screen.
Sidebar fields are removed from the main column automatically. So listing a
field in both places is safe. You can also leave fields: [], and the main
column generates itself from whatever the sidebar did not take.
Fields that react
Use the object form of a layout item, and four keys become handlers.
| Key | Returns | Does |
|---|---|---|
hidden | boolean | Removes the field from the form |
readOnly | boolean | Shows the value, refuses edits |
disabled | boolean | Greys the control out |
compute | any value | Writes the value into the field |
Each takes a plain boolean, a function, or { handler, deps?, debounce? }.
compute takes the last two only.
{
type: "section",
label: "Publishing",
fields: [
{ field: f.banReason, hidden: ({ data }) => !data.banned },
{
field: f.slug,
compute: {
handler: ({ data, prev }) =>
data.slug && prev?.data?.title === data.title
? undefined
: slugify(String(data.title ?? "")),
deps: ({ data }) => [data.title, data.slug],
debounce: 300,
},
},
],
}Returning undefined from compute leaves the field alone. That is how the
example above stops overwriting a slug a person edited by hand.
A field with a compute stays editable. The handler writes the value when a
dependency changes, so anything typed in between survives until the next change.
They run on the server
A handler never ships to the browser. The panel batches the tracked dependencies
to /admin/reactive. That route finds your handler on the builder and calls it.
So a handler may read the database, and it costs a round trip.
The context is { data, sibling, prev, ctx }.
| Key | Holds |
|---|---|
data | The whole form, as it stands now |
sibling | The neighbouring values, inside an array or object field |
prev | The same two, as they stood before this change |
ctx | { db, user, locale }, plus req when there is one |
The route requires session.user.role === "admin", same as the rest of the
panel.
Dependencies are inferred
You rarely write deps. The server runs the handler once against a recording
proxy while it builds the schema. Every value the handler reads becomes a
dependency, and that list is what the panel watches.
Inference only sees what the handler actually touched on that one call. A read
behind an if can be missed. Write deps yourself when that happens, either as
a string array or as a function returning the values. debounce is
milliseconds.
Passing props to a field component
props forwards anything to the field's own component. Values can be static
JSON, or the same handler shapes as above.
{ field: f.author, props: { filter: ({ data }) => ({ team: data.team }) } }
{ field: f.counselorId, props: { filter: { role: "admin" } } }A static value ships with the schema. A function stays on the server and
resolves through the same /admin/reactive route. That is how a relation
picker narrows itself against a value elsewhere on the form.
Globals
A global takes the same .form(), with v.globalForm() in place of
v.collectionForm(). Sections, tabs, sidebars and reactive handlers all behave
identically. It has no .list(), because there is only ever one row.
Where each topic lives
| Topic | Page |
|---|---|
| The other four methods | Collections and globals |
| A live page beside this form | Preview |
| Stacked content a person arranges | Blocks |
Every f.* type and its control | Fields |
| Writing a field component of your own | Custom views |
Next
Actions covers the buttons around this form, and how to add one that runs code of yours.
The list
One .list() call chooses the renderer and everything on it. Columns, sorting, filter presets, grouping, drag-to-reorder and the buttons around the rows.
Actions
An action is a button in the panel with a handler on the server. It can ask for input first, confirm before it runs, and then invalidate, redirect or just say it worked.