QUESTPIE
GuidesBuild a plugin

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().

View markdown

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.

src/questpie/server/collections/products.ts
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.

src/questpie/server/fields/color.ts
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.

KeyWhat it decides
columnFactoryThe Drizzle column, from the field name
schemaFactoryThe Zod schema the value is validated against
operatorSetWhich 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.

src/questpie/server/fields/slug.ts
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 generate
export 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.

StepWhat it gives the consumer
A fields.ts in the module, default-exporting your factoriesThe type on f
factoryImports on the fieldTypes category in your pluginThe factory at runtime
your plugin's server target
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.

src/questpie/admin/fields/color.tsx
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

TopicPage
Every built-in type and its columnFields
Adding a method to collection()Builder methods
Shipping the type in a packagePublishing
The fieldTypes category in fullPlugins

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.

On this page