A post has an author, a category and any number of tags, and it has a cover image. Three field types cover all of it.
The target resources must exist
A relation field offers options for a resource, not a model. authorId
cannot be picked until a resource manages User in the same panel, so create
the three targets first — they are small:
import { BaseResource, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent } from '@adonia/core'
import Tag from '#models/tag'
export default class TagResource extends BaseResource<typeof Tag> {
static override model = Tag
static override slug = 'tags'
static override labels = { singular: 'Tag', plural: 'Tags' }
static override navigationIcon = 'tag'
static override navigationGroup = 'Taxonomy'
static override navigationSort = 21
static override recordTitle = 'name'
static override policy = () => import('#policies/tag_policy')
schema(s: SchemaBuilder): SchemaComponent[] {
return [
s
.section('Tag')
.components([
F.text('name').label('Name').required().maxLength(60).live().sets('slug', 'slugify'),
F.text('slug').label('Slug').required().maxLength(80).unique().readonlyOn('edit'),
]),
]
}
}examples/blog-admin/app/adonia/resources/tag_resource.ts — category_resource.ts
and user_resource.ts are the same shape
navigationGroup collapses categories and tags into one sidebar group;
navigationSort orders the top level (posts 0, Taxonomy 20, users 30).
recordTitle is how one row is named — the relation picker falls back to it
when a field declares no optionLabel.
belongsTo
F.belongsTo('authorId')
.label('Author')
.resource('users')
.optionLabel('fullName')
.searchable()
.required(),
F.belongsTo('categoryId')
.label('Category')
.resource('categories')
.optionLabel('name')
.preload(50)
.nullable()The field binds the foreign key column, and the relation name is the
attribute with its Id suffix stripped — authorId → author, which must be
a real Lucid relation on Post. .resource('users') names the target
explicitly; without it Adonia infers it by finding the registered resource
whose static model is the related model.
The two loading strategies differ on purpose:
.searchable()ships no options in the descriptor and attaches a capability URL instead —/admin/posts/field/authorId/options— which the client queries as you type. That is the right default for users..preload(50)ships the first 50 options inline and still attaches the URL. Right for a closed set like categories.
Both go through the same resolver, which requires viewList on the target
resource and applies its static query() scope — so the options a user is
offered and the keys the validator accepts cannot disagree.
optionLabel('fullName') does three jobs: it labels the option, it is the
column ?q= searches, and it is what makes the field contribute the derived
index column author.fullName. Without it a relation field is not tabulatable
at all.
belongsToMany
F.belongsToMany('tags').label('Tags').resource('tags').optionLabel('name').searchable()State is a plain array of ids — [3, 7] — validated with one batched
WHERE id IN (…) rather than one query per id, and hydrated from the preloaded
relation (never a lazy query).
The pivot is written after the row is saved, inside the same transaction,
as a sync(keys, true) — a diff, not a rebuild. Editing [typescript, adonisjs] into [typescript, react] detaches only adonisjs; untouched pivot
rows keep their surrogate ids and any extra columns. Add extra columns to the
form with .pivotFields([...]), which widens each element from a bare key to
{ id, …pivot } and rides as the node’s children.
The image field
An image field stores a Drive object key — never bytes, never a URL. Uploads therefore need Drive:
node ace add @adonisjs/drive # select "Local filesystem"That writes config/drive.ts (an fs disk under storage/, served at
/uploads), registers the provider, and adds DRIVE_DISK=fs to your env. Then:
F.image('coverImage')
.label('Cover image')
// MUST precede dimensions(): the rule set is filtered against the accepted
// types at call time, and the default set includes image/avif, whose header
// Adonia cannot measure.
.acceptedTypes(['image/png', 'image/jpeg', 'image/webp'])
.dimensions({ minWidth: 640, ratio: '16:9' })
.directory('posts/covers')
.maxSize(4)
.previewWidths([320, 640, 1280])
.nullable()The ordering comment is not decoration: dimensions() reads the accepted set
at call time and throws E_ADONIA_INVALID_CONFIG if any accepted type has a
header Adonia cannot measure. AVIF is in the default image set and is one of
those, so a bare F.image('cover').dimensions({…}) fails at boot.
maxSize(4) can only tighten config/adonia.ts’s uploads.maxSizeMb, never
lift it.
The upload round trip
The node carries urls.upload. One multipart part named file goes in:
curl -X POST /admin/posts/field/coverImage/upload -F file=@cover.png{ "key": "adonia/tmp/6f1c…-a3/cover-photo.png", "url": "/uploads/adonia/tmp/…" }The bytes are stored under the temp prefix first and verified from the disk
afterwards — magic bytes, then size, then dimensions — because verifying a
buffer you have not stored tells you nothing about the object you will serve. A
rejection deletes the temp object and answers 422; the response body never
says which check failed, since that is only interesting to somebody probing
the sniffer. The filename is sanitized (cover photo.png →
cover-photo.png), and a UUID segment makes the key unguessable.
The form then submits that key like any other string. On save the temp object is promoted into the field’s directory:
posts/covers/6f1c….pngand the column holds that key. Keys dropped from the field are deleted after the transaction commits. A save that does not change the field never touches Drive at all — which is why an app with no uploads never needs the peer installed.
Next: reactivity.