Skip to content

6. Relations and media

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.tscategory_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 — authorIdauthor, 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.pngcover-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….png

and 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.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close