Field types
Put your own type on the f proxy. One file in fields/ gives you a column, a Zod schema and a where operator set. A second file gives the admin its control, and f.color() reads the same as f.text().
You want f.color() beside f.text(). This page writes one, wires it into the
generator, and gives it a control in the admin.
Try .drizzle() first
If you only need different column behaviour, keep the nearest built-in type and tweak the column. You keep its validation, its admin control, its localization and its operators.
import { sql } from "questpie/builders";
import { collection } from "#questpie/factories";
export const products = collection("products").fields(({ f }) => ({
sku: f
.text(64)
.required()
.drizzle((column) => column.$type<`sku_${string}`>()),
createdAt: f.datetime().drizzle((column) => column.default(sql`now()`)),
}));.drizzle() replaces the column and leaves the field's value type alone. Reach
for .zod() when the validation has to change too.
Write a new type when you need a different column, a different operator set, or a different admin control.
Write the type
fieldType(name, config) is the factory. create returns the field's runtime
state, and that state is the whole definition.
import { fieldType, selectSingleOps } from "questpie/builders";
import { varchar } from "questpie/drizzle-pg-core";
import { z } from "zod";
export const colorFieldType = fieldType("color", {
create: () => ({
type: "color",
columnFactory: (name: string) => varchar(name, { length: 9 }),
schemaFactory: () => z.string().regex(/^#[0-9a-fA-F]{6}$/),
operatorSet: selectSingleOps,
notNull: false,
hasDefault: false,
localized: false,
virtual: false,
input: true,
output: true,
isArray: false,
}),
});Every key above is required. Three of them do the real work.
| Key | What it decides |
|---|---|
columnFactory | The Drizzle column, from the field name |
schemaFactory | The Zod schema the value is validated against |
operatorSet | Which where operators the field accepts |
The rest are the starting state of the chain. .required() flips notNull,
.default() flips hasDefault, .localized() flips localized, and so on.
Operator sets ship with the framework, so you rarely write one. stringOps,
numberOps, booleanOps, dateOps, selectSingleOps, selectMultiOps,
objectOps and basicOps all come from questpie/builders. Reuse the one that
matches how people will filter your field.
Add chain methods
A bare field type gets the common methods only. Pass methods to add your own.
import { type Field, fieldType, stringOps } from "questpie/builders";
import { varchar } from "questpie/drizzle-pg-core";
import { z } from "zod";
export const slugFieldType = fieldType("slug", {
create: (maxLength = 255) => ({
type: "slug",
columnFactory: (name: string) => varchar(name, { length: maxLength }),
schemaFactory: () =>
z
.string()
.regex(/^[a-z0-9-]+$/)
.max(maxLength),
operatorSet: stringOps,
// the rest of the state, as above
}),
methods: {
maxLen: (field: Field<any>, n: number) => field.derive({ maxLength: n }),
},
});Each method takes the field plus its arguments and returns a field. Use
.derive() to change state. Its parameter type omits the identity keys. A
method cannot swap type, columnFactory, schemaFactory, operatorSet,
innerField, isArray or virtual out from under the field.
Declaring any method wraps the factory in a proxy that re-wraps after every
call. That is what keeps f.slug().maxLen(80).required().label("Slug") working
in any order.
Make it appear on f
In an app, put the file in fields/ and generate.
questpie generateexport const paints = collection("paints").fields(({ f }) => ({
hex: f.color().required(),
}));The generator scans fields/ for fieldType() calls. It unwraps each one to
its factory and merges it into the field definitions the builders are handed.
The name you passed is the key, so fieldType("color", …) gives f.color().
In a package it takes two steps, because a consuming app never scans your source.
| Step | What it gives the consumer |
|---|---|
A fields.ts in the module, default-exporting your factories | The type on f |
factoryImports on the fieldTypes category in your plugin | The factory at runtime |
categories: {
fieldTypes: {
dirs: ["fields"],
prefix: "ftype",
factoryImports: [{ name: "acmeFields", from: "@acme/thing/fields" }],
},
},That is exactly how @questpie/admin ships richText and blocks. Miss the
second step and the type resolves while the call throws at runtime.
Give it a control
The admin needs a React component under the same name. Put it in the admin
target's fields/ directory.
import { field, type FieldComponentProps } from "@questpie/admin/client";
function ColorField({ value, onChange }: FieldComponentProps<string>) {
return (
<input
type="color"
value={value ?? "#000000"}
onChange={(e) => onChange?.(e.target.value)}
/>
);
}
export default field("color", { component: ColorField });The name is the only link between the two files. questpie add field color
scaffolds this side for you.
Where each topic lives
| Topic | Page |
|---|---|
| Every built-in type and its column | Fields |
Adding a method to collection() | Builder methods |
| Shipping the type in a package | Publishing |
The fieldTypes category in full | Plugins |
Next
Builder methods is the other half of the same seam. A field type adds a value. A builder method adds a place to put one.
Builder methods
Add a typed method to collection(), global() or any field. You declare where the value lands and what type it takes. The generator writes the method into your app's own factories file.
The scope resolver
appConfig({ context }) turns request data into per-request state. What it receives, where the result lands, which key names are taken, and why it does nothing at all in a job.