Rich text field
The TipTap editor field. It stores a structured document in jsonb, or a markdown string in text, and renders a configurable WYSIWYG editor in the admin.
| Call | Column | Value | Validation |
|---|---|---|---|
f.richText() | jsonb | TipTapDocument | recursive node schema rooted at type: doc |
f.richText({ mode: "json" }) | jsonb | TipTapDocument | the same |
f.richText({ mode: "markdown" }) | text | string | z.string() |
mode is the only option the factory takes, and it defaults to "json". The
mode is recorded on the field metadata as outputMode, which is how the editor
knows whether to emit a document or a string.
import { collection } from "#questpie/factories";
export const posts = collection("posts").fields(({ f }) => ({
title: f.text(255).required(),
body: f.richText().label("Body").required(),
notes: f.richText({ mode: "markdown" }).label("Internal notes"),
}));`f.richText` needs the admin module
@questpie/admin contributes this type, so it appears on f only when that
module is enabled and you import collection from #questpie/factories.
Reading f.richText without it throws Unknown field type: "richText".
Chain
richText declares no methods of its own. The first four rows are base field
methods that work on any type. .admin() is not one of them. The admin plugin
registers it as a field extension and codegen wraps every factory with it.
| Method | Effect here |
|---|---|
.required() | NOT NULL, and required on insert |
.default(value) | A TipTapDocument in json mode, a string in markdown. A factory or a raw SQL also works |
.localized() | One document per locale |
.label(text) / .description(text) | Label and helper text on the admin form, each an I18nText |
.admin(config) | Editor options, listed below |
Editor options
.admin() config travels to the browser with the collection schema, so the
editor reads plain data. These are the keys it acts on.
| Option | Type | Default | Effect |
|---|---|---|---|
preset | "minimal" | "simple" | "standard" | "advanced" | none | Picks a feature set |
features | booleans keyed by feature | none | Overrides single features on top of the preset, or of the defaults |
placeholder | string | Start writing... | Shown while the document is empty |
showCharacterCount | boolean | false | Prints the word and character readout, needs the characterCount feature |
maxCharacters | number | none | Hard limit in the editor, needs the characterCount feature |
enableImages | boolean | true | Set false to hide image insertion, the extension stays on |
imageCollection | string | none | Which upload collection inline images go to |
enableMediaLibrary | boolean | true | Set false to drop the media picker from the image popover |
content: f.richText().label("Content").admin({
preset: "simple",
features: { image: true },
showCharacterCount: true,
maxCharacters: 5000,
imageCollection: "media",
}),Inline image uploads go through the collection you name. Without
imageCollection the editor falls back to the admin's default upload
collection, or to the only one your app has. Several collections and no default
raises an error naming them.
Field `.admin()` takes `unknown`
The generated .admin() on a field is typed unknown, so a misspelled key
compiles and then does nothing. Collection-level .admin() is typed. Read the
table above as the contract for this field.
Presets
| Preset | What it enables |
|---|---|
| no preset | Every feature except toolbar |
minimal | Only bubbleMenu, history, bold, italic, underline and link |
simple | Everything except codeBlock, align, image, table, tableControls |
standard | Every feature |
advanced | The same set as standard |
The feature keys are toolbar, bubbleMenu, slashCommands, history,
heading, bold, italic, underline, strike, code, codeBlock,
blockquote, bulletList, orderedList, horizontalRule, align, link,
image, table, tableControls, characterCount.
`toolbar` is inert
Twenty of those twenty-one keys reach the editor. Nothing reads toolbar, so
no preset renders one. Formatting runs through the bubble menu and slash
commands. toolbar is also the only key separating no preset from standard,
which leaves those two identical.
Filtering
A json-mode field defines five operators of its own.
| Operator | Operand | Matches |
|---|---|---|
contains | string | Any text node in the document, case-insensitive |
isEmpty | boolean | Null, a doc with no content, or no non-whitespace text |
isNotEmpty | boolean | A document with non-whitespace text |
isNull | boolean | The column is NULL |
isNotNull | boolean | The column is not NULL |
const { docs } = await app.collections.posts.find({
where: { body: { contains: "changelog", isNotEmpty: true } },
});contains reads the words an editor typed, not node types or attribute values.
For which section types a page holds, reach for
f.blocks() and its hasBlockType operator.
In json mode the four flag operators ignore their operand. { isEmpty: false }
asserts the same condition as { isEmpty: true }, so negate by using the
opposite operator or a NOT group.
A markdown-mode field stores plain text, so it filters with the standard string
operators instead: eq, ne, contains, startsWith, endsWith, like,
ilike, in, isNull, isNotNull and the rest of that set, matching against
the raw markdown. isEmpty and isNotEmpty belong to json mode.
The `where` slot is not narrowed
A text column rejects a bad operator name at compile time. A rich text column
does not, because its generated where type is a broad record. An operator
this field lacks is dropped from the query rather than rejected, so check
names against the table.
Types
The document types ship from @questpie/admin/fields.
interface TipTapDocument {
type: "doc";
content?: TipTapNode[];
}
interface TipTapNode {
type: string;
attrs?: Record<string, any>;
content?: TipTapNode[];
marks?: Array<{ type: string; attrs?: Record<string, any> }>;
text?: string;
}import type { TipTapDocument } from "@questpie/admin/fields";
type Post = typeof posts.$infer.select;
// ^? { ...; body: TipTapDocument; notes: string | null }That subpath also exports the richText factory itself, richTextFieldType,
RichTextFieldState, the augmentable RichTextFieldMeta and RichTextFeature.
Ignore that last one, it is a stale union nothing reads and it is not the
feature list above. RichTextMode and RichTextOptions never leave the package.
Related
- Fields, every type and the column it produces
- Blocks, stacked and reorderable sections rather than one prose document
- Textarea field, a plain multi-line string
- Validation, tuning the generated Zod schemas
JSON field
One jsonb column with no declared keys, for data whose shape you do not control. Three ways to type the value, and only one of them also narrows the filter.
Upload field
f.upload() holds the id of a row in an upload collection. One asset in a varchar(36), or a gallery through a junction, and the admin renders a file picker rather than a record picker.