QUESTPIE
SchemaFields

Select field

f.select() takes a list of options, or a handler that produces one. A static list narrows the read type to the literal union of its values and derives a z.enum from them.

View markdown
CallColumnRead typeSchema
f.select([{ value, label }, …])varchar(n), n is the longest value, min 50union of the valuesz.enum(values)
f.select([])varchar(255)stringz.string()
f.select({ handler, deps })varchar(255)stringz.string()
.enum(name) on a static listthe Postgres enum type nameunchangedunchanged
.array()jsonba list of the same valuez.array(…)
src/questpie/server/collections/posts.ts
import { collection } from "#questpie/factories";

export const posts = collection("posts").fields(({ f }) => ({
	status: f
		.select([
			{ value: "draft", label: "Draft" },
			{ value: "published", label: "Published" },
		])
		.required()
		.default("draft"),
}));

posts.status reads back as "draft" | "published", and .default("archived") does not compile. Drop the .required() and the read type picks up null. The column is varchar(50), because 50 is the floor and no value is longer.

The option object

value and label are required. The other four shape the admin, and QUESTPIE serializes all six into the introspection payload.

KeyTypeWhat reads it
valuestring | numberThe stored value. A number is written as its string
labelI18nTextThe dropdown row and the list cell
descriptionI18nTextA second line under the label in the dropdown
disabledbooleanThe dropdown row, which stops responding
iconComponentReferenceLeads the dropdown row and the list cell
classNamestringClasses on the dropdown row and on the list cell

I18nText is a plain string, a locale map such as { en: "Ready" }, or a { key } reference your i18n adapter resolves. Icons come from the c proxy that arrives beside f.

src/questpie/server/collections/deployments.ts
import { collection } from "#questpie/factories";

export const deployments = collection("deployments").fields(({ f, c }) => ({
	health: f.select([
		{
			value: "ready",
			label: { en: "Ready", sk: "Pripravené" },
			description: "Runtime is healthy",
			icon: c.icon("ph:check-circle"),
			className: "border-emerald-500/30 bg-emerald-500/10",
		},
		{ value: "degraded", label: "Degraded", icon: c.icon("ph:warning") },
	]),
}));

The union needs the values in the call

Narrowing comes from a const type parameter, so the literals have to be visible where you call f.select(). Inline options narrow. A hoisted array needs as const or the read type falls back to string.

.enum(name)

The one method select adds. It builds a fresh pgEnum(name, values) from the static values and swaps the column factory to it. Nothing else moves, not the read type, not the derived schema, not the operators.

status: f
	.select([
		{ value: "draft", label: "Draft" },
		{ value: "published", label: "Published" },
	])
	.enum("post_status"),

`.enum()` can end up declaring nothing

On f.select([]) or a handler there are no values, so the call is a silent no-op and the column stays varchar(255). Even on a static list, app.getSchema() hands questpie push the tables only, and the pgEnum stays closed over inside the column factory. Read the generated SQL.

Filtering

A select carries the single-value operator set. The typed where map is exactly these six.

OperatorOperandSQL
eq / neone value= / <>
in / notIna list of valuesIN / NOT IN
isNull / isNotNullbooleanIS NULL / IS NOT NULL, inverted by false
const { docs } = await app.collections.posts.find({
	where: { status: { ne: "draft" } },
});

.array()

.array() is the shared modifier, not something select defines. On a select it turns the column into jsonb, wraps the schema as z.array(z.enum(values)), and takes .minItems() and .maxItems(). The admin renders one dropdown per row.

tags: f
	.select([
		{ value: "news", label: "News" },
		{ value: "guide", label: "Guide" },
	])
	.array()
	.maxItems(5),

An arrayed select keeps the single-value `where`

.array() swaps the runtime set for selectMultiOps, but the select carries its own whereInput through the wrapping. So the typed map stays eq / ne / in / notIn on one value, eq runs against the whole array, and the membership operators are unreachable from the typed clause.

A handler instead of a list

Pass { handler, deps } when the choices are not known at build time. The field then stores a plain string, and introspection ships an empty option list plus the form paths to watch.

KeyTypeDefault
handler(ctx) => OptionsResult | Promise<…>Required
depsstring[] or (ctx) => any[]None, the watch list is empty

Name the fields you read. Introspection tracks a deps function, never the handler, so omitting deps ships an empty watch list and nothing refetches as the form changes.

The handler receives { data, sibling, search, page, limit, ctx }. data is the current form, page counts from 0, limit defaults to 20 and caps at 100, and ctx is { db, user, locale } plus an optional req, with db typed unknown. Return { options, hasMore?, total? }. Each option is a value and a label, and that pair is all that crosses the wire.

city: f.select({
	handler: async ({ data }) =>
		data.country === "us"
			? { options: [{ value: "nyc", label: "New York" }] }
			: { options: [] },
	deps: ["country"],
}),

Fetching them takes one hook

The admin serves these options over its own route, and useFieldOptions from @questpie/admin/client calls it with the form data, search and page. Wire that hook into your own field component. The built-in control renders the options it was handed, which for a handler is none.

In the admin

The form renders a dropdown, with a search box once the list passes eight options. The list cell renders a badge carrying the option's icon, label and className. Four keys sit on .admin() for a select, on top of the ones every field shares. Only the first reaches the control.

KeyTypeEffect
clearableboolean, default trueThe clear button, and Backspace or Delete
displayAs"dropdown" | "radio" | "checkbox" | "buttons"Accepted by the type, read by nothing
searchablebooleanAccepted by the type, read by nothing
creatablebooleanAccepted by the type, read by nothing

Array is the modifier itself, on every type rather than this one.

Relations is what you want when the choices are rows in another table, with a real foreign key.

Validation covers the derived schema and .zod().

Reading and writing covers the rest of the query language around the operators above.

On this page