Skip to content

2. The Post resource

A resource is one class that binds a Lucid model to a tree of fields. The generator writes the first draft by reading the table.

node ace adonia:resource Post
[ success ] create app/adonia/resources/post_resource.ts
[ info ] Next: start the dev server (`node ace serve --hmr`) — the
         indexAdoniaResources() hook picks the resource up and regenerates
         .adonisjs/adonia/.

What it generated

Verbatim, from the blog-admin schema (SQLite, migrations run):

import { BaseResource, C, F } from '@adonia/core'
import type { SchemaBuilder, SchemaComponent, TableBuilder } from '@adonia/core'
import Post from '#models/post'

/**
 * Post resource (TECH_SPEC §6.1), generated by `node ace adonia:resource Post`
 * from the `Post` model's column metadata.
 *
 * Primary keys, timestamps and `serializeAs: null` attributes are omitted from
 * the form on purpose — see the generator docs in docs/generators.md.
 */
export default class PostResource extends BaseResource<typeof Post> {
  static override model = Post
  static override slug = 'posts'
  static override labels = { singular: 'Post', plural: 'Posts' }

  schema(s: SchemaBuilder): SchemaComponent[] {
    return [
      s.section('Post').components([
        F.number('authorId').label('Author id').required(),
        F.textarea('body').label('Body').required().rows(6),
        F.number('categoryId').label('Category id').nullable(),
        F.text('coverImage').label('Cover image').nullable().maxLength(255),
        F.datetime('publishedAt').label('Published at').nullable(),
        F.text('slug').label('Slug').required().maxLength(255),
        F.select('status')
          .label('Status')
          .options({ draft: 'Draft', review: 'Review', published: 'Published' })
          .required(),
        F.textarea('subtitle').label('Subtitle').nullable().rows(6),
        F.text('title').label('Title').required().maxLength(255),
      ]),
    ]
  }

  override table(t: TableBuilder): TableBuilder {
    return t.columns([
      C.number('authorId').label('Author id').sortable(),
      C.text('body').label('Body').sortable().searchable(),
      C.number('categoryId').label('Category id').sortable(),
      C.text('coverImage').label('Cover image').sortable().searchable().link('detail'),
      C.date('publishedAt').label('Published at').sortable(),
    ])
  }
}

Everything in it is a fact about the database: status is an enum, so it became a select whose options were read out of the CHECK constraint; published_at is nullable, so the field is .nullable(); id, created_at and updated_at are excluded because primary keys and timestamps are never form fields. The order is the introspection order (alphabetical here), the column list is capped at five, and the row link landed on the first string column — which is why coverImage is currently the clickable one.

That is a scaffold, not a design. The rest of this chapter reshapes it.

Layout: a grid, a section, an aside

Editors read a post form as “the writing” and “the settings”. Say exactly that:

s.grid().columns(3).components([
  s.section('Content').columnSpan(2).components([
    // title, subtitle, slug, body, cover image
  ]),
  s.aside().components([
    // status, published at, author, category, tags
  ]),
])

examples/blog-admin/app/adonia/resources/post_resource.ts

Layout components come from s, the builder handed to schema(). They carry no state: a grid is a grid node with key: null, and the field keys inside it stay flatstate.title, never state.content.title. That is a protocol rule, not a convention, and it is what lets you move a field between sections without breaking a validator, a fill set or a client.

A single grid() returned as the sole root is used as the tree root verbatim. Sections, asides, tabs, wizards and fieldsets nest through .components([…]).

Fields

State-bearing fields come from F, imported from @adonia/core. The finished content section:

s.section('Content').columnSpan(2).components([
  F.text('title').label('Title').required().maxLength(200),
  F.text('subtitle')
    .label('Subtitle')
    .helper('Only used for special posts.')
    .maxLength(200),
  F.text('slug').label('Slug').required().maxLength(220).unique(),
  F.textarea('body').label('Body').rows(12).required(),
])

and the settings aside, minus the relations chapter 6 adds:

const STATUS_OPTIONS: readonly FieldOption[] = [
  { value: 'draft', label: 'Draft', color: 'gray' },
  { value: 'review', label: 'In review', color: 'amber' },
  { value: 'published', label: 'Published', color: 'green' },
]

s.aside().components([
  F.select('status').label('Status').options(STATUS_OPTIONS).default('draft').required(),
  F.datetime('publishedAt').label('Published at'),
])

Three things are happening that are worth naming.

Every field is three concerns at once. F.text('title') declares the state contract (which attribute it reads and writes), the projection rules (which contexts it appears in), and the validation contract (required, maxLength(200)). One object, compiled three ways: the create/edit form, the detail page, and — unless you declare a table — the index columns.

Options with a color change the projection. Because STATUS_OPTIONS carries colours, status renders as a badge on the detail page and as a badge cell in the table instead of plain text. Drop the colours and it degrades to text. Nothing else changes.

default('draft') prefills the form. It is not a server-side fallback: a submission that omits a required key is still invalid. The client sends the prefilled value back like any other.

The field types used here are a fraction of what ships — number, slider, radio, checkbox, toggle, checkboxList, date, time, color, hidden, repeater, keyValue, json, code, markdown, richText, file, image, belongsTo, belongsToMany. Each has a page in the fields reference.

Registering it

Nothing to register. The codegen hook scans app/adonia/resources/, so saving the file regenerates .adonisjs/adonia/resources.ts:

const registry = {
  posts: () => import('#adonia/resources/post_resource'),
} satisfies AdoniaResourceRegistry

and the panel — which already imports that registry — serves /admin/posts, /admin/posts/create, /admin/posts/:id, /admin/posts/:id/edit and the four JSON callbacks behind them.

A resource is instantiated per request and holds no request state; the compiled descriptors and validators are what get cached. Keep the constructor free of work.

Next: validation.

Navigation

Type to search…

↑↓ navigate↵ selectEsc close