QUESTPIE
AdminCollections

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.

View markdown

What goes in it

.form() takes three keys. fields is required.

KeyHolds
fieldsThe main column, as a list of layout items
sidebarA fixed column beside it, with its own list
viewThe 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.

ItemRenders
f.titleThat 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],
}
KeyDefaultEffect
labelnoneHeading above the group
descriptionnoneOne muted line under the heading
wrapperflatcollapsible puts it in an accordion
defaultCollapsedfalseStarts a collapsible section closed
layoutstackinline wraps in a row, grid uses columns
columns2Grid columns. 1 to 6
gapnoneCustom gap, in quarter rems
hiddenfalseDrops the whole section
classNamenoneClasses 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.

KeyReturnsDoes
hiddenbooleanRemoves the field from the form
readOnlybooleanShows the value, refuses edits
disabledbooleanGreys the control out
computeany valueWrites 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 }.

KeyHolds
dataThe whole form, as it stands now
siblingThe neighbouring values, inside an array or object field
prevThe 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

TopicPage
The other four methodsCollections and globals
A live page beside this formPreview
Stacked content a person arrangesBlocks
Every f.* type and its controlFields
Writing a field component of your ownCustom views

Next

Actions covers the buttons around this form, and how to add one that runs code of yours.

On this page