Skip to content
AzuAzu

Resources

You want an admin page for a model. In Azu, that’s a resource: one file that points at a Lucid model and borrows its policy and validators.

Here’s the demo store’s product resource in full:

app/admin/resources/product_resource.ts
/**
* Products in the demo store, loaded by convention from
* app/admin/resources. The thumbnail uses Drive's default local disk, so
* the demo runs offline.
*/
import { defineResource, column, field, filter, action } from '@mezielabs/azu'
import Product from '#models/product'
import ProductPolicy from '#policies/product_policy'
import feature from '#admin/actions/feature'
import {
createProductValidator,
restockValidator,
updateProductValidator,
} from '#validators/product'
export default defineResource(Product, {
label: 'Products',
group: 'Catalog',
icon: 'package',
order: 1,
title: 'name',
search: ['name', 'sku'],
policy: ProductPolicy,
validators: {
create: createProductValidator,
update: updateProductValidator,
// The update validator's unique rule reads productId
meta: ({ record }) => ({ productId: record?.id }),
},
query: (q) => q.preload('category'),
columns: [
column.image('thumbnail'),
column.text('name').sortable(),
column.decimal('price').format('currency', 'NGN').sortable(),
column.belongsTo('category', 'name'),
column.badge('status').colors({ draft: 'gray', published: 'green' }),
// inertia/admin/components/stock_bar.tsx
column.custom('stock', 'StockBar').label('In stock'),
column.text('sku').hiddenByDefault(),
],
fields: [
field.text('name').section('Details'),
// inertia/admin/components/sku_input.tsx
field.text('sku').component('SkuInput').section('Details'),
field.decimal('price').section('Pricing').min(0),
field.belongsTo('category').section('Organisation').searchable('name'),
field.manyToMany('tags').section('Organisation').searchable('name'),
field
.select('status')
.section('Organisation')
.options({ draft: 'Draft', published: 'Published' }),
field.richText('description').section('Media'),
field.image('thumbnail').section('Media').maxSize('2mb'),
],
filters: [
filter.select('status', { draft: 'Draft', published: 'Published' }),
filter.belongsTo('category'),
filter.dateRange('createdAt'),
filter.custom('Low stock', (q) => q.where('stock', '<', 10)),
filter.boolean('isFeatured').label('Featured'),
],
actions: [
action
.bulk('publish')
.confirm('Publish the selected products?')
.ability('publish')
.handle(async (products, { trx }) => {
await Product.query({ client: trx })
.whereIn(
'id',
products.map((p) => p.id)
)
.update({ status: 'published' })
return { message: `${products.length} products published` }
}),
// A dialog field, validated by its own VineJS validator
action
.row('restock')
.ability('restock')
.fields(
[field.number('quantity').section('Stock adjustment').help('Added to the current stock')],
restockValidator
)
.handle(async (product, { input }) => {
product.stock += input.quantity
await product.save()
return { message: `${product.name} now has ${product.stock} in stock` }
}),
// A reusable action from azu:action
feature,
],
})

Azu turns it into a paged list, a detail page, and create and edit forms. The rest of the guides take each part in turn.

Resource definitions are typed against the model. A typo like column.text('nmae') fails tsc, not your client’s demo. You don’t pass any generics; Azu infers everything from the model.

Option What it does
label The name in the navigation and page titles. Defaults to the model’s name, pluralised.
singularLabel One record’s name, as in “New product”. Defaults to the model’s name.
group, icon, order Where it sits in the navigation. See Home screen and navigation.
title The column that names a record: in breadcrumbs, links, and related pickers.
search Columns the list’s search box matches. See Columns and lists.
policy The Bouncer policy that decides who sees and does what. See Permissions.
validators The VineJS validators the forms save through. See Validation.
query A hook on every query the resource runs. Preload relations here, or scope records.
columns The list’s columns. See Columns and lists.
fields The form and detail fields. See Fields.
filters The list’s filters. See Filters.
actions Row and bulk actions. See Actions.
beforeSave, afterSave Hooks around a form save. Each gets the record and the HTTP context, in the save’s transaction.
beforeDelete A hook before each record is deleted. See Actions.
slots Your components on the resource’s pages. See Customising.

Files in app/admin/resources load automatically. Each one appears in the navigation, grouped by group, for users whose policy allows viewList.

The demo sets the folder explicitly:

config/azu.ts
/**
* Resources in this directory load automatically. Set autoload to
* false to register them in start/azu.ts instead.
*/
resources: { autoload: 'app/admin/resources' },

Prefer to register resources yourself? Turn autoload off:

config/azu.ts
resources: { autoload: false },

Then register each one in start/azu.ts. The demo’s file shows how, in its comment:

start/azu.ts
/*
|--------------------------------------------------------------------------
| Admin resources
|--------------------------------------------------------------------------
|
| Azu loads every resource in app/admin/resources automatically. To
| register resources here instead, set resources.autoload to false in
| config/azu.ts, then register each one:
|
| import azu from '@mezielabs/azu/services/main'
| import productResource from '#admin/resources/product_resource'
|
| azu.register(productResource)
|
*/
export {}

Note: registering the same resource twice does nothing. Two resources with the same URL name stop the app from booting.

node ace azu:resource Product writes a resource from a model, with its validators. Quick start walks through it, and Ace commands lists the flags.