Views
Ship a screen of the admin as data. Lists, forms, tabs and actions bound to your abilities, drawn by Lettern like every place of the admin.
On this page
Every place of the admin is a view: a tree of components, written as data. A list binds to the ability that lists your records, a form to the ability that loads it and the one that saves it, and a button to the ability it runs. Lettern draws every view with the same renderer, so your plugin's screens look and behave like the rest of the admin, in light and dark, on a phone and in every language. You write no React for them.
#Register a view
A view has an id, a path in the admin, and its tree. Register it in your provider's boot():
use Cms\Users\User;
$this->cms()->views()->register('reviews', '/reviews', fn (User $user) => [
'type' => 'ui/page',
'props' => ['title' => __('Reviews'), 'icon' => 'star', 'actions' => [
['label' => __('New Review'), 'icon' => 'plus', 'variant' => 'primary', 'ability' => 'reviews/create',
'form' => ['title' => __('New Review'), 'fields' => [['handle' => 'title', 'type' => 'text', 'label' => __('Title'), 'required' => true]]]],
]],
'children' => [
['type' => 'ui/records', 'props' => [
'label' => __('Reviews'),
'source' => ['ability' => 'reviews/list'],
'columns' => [
['key' => 'title', 'label' => __('Title')],
['key' => 'rating', 'label' => __('Rating'), 'format' => 'number', 'align' => 'end'],
['key' => 'status', 'label' => __('Status'), 'format' => 'badge', 'tones' => ['published' => 'success']],
['key' => 'updatedAt', 'label' => __('Updated'), 'format' => 'ago'],
],
'open' => ['label' => __('Open'), 'to' => '/reviews/{row.id}'],
'actions' => [['label' => __('Delete'), 'variant' => 'danger', 'ability' => 'reviews/delete', 'input' => ['id' => '{row.id}'], 'confirm' => ['title' => __('Delete {row.title}?')]]],
'empty' => ['icon' => 'star', 'title' => __('No reviews yet')],
]],
],
], permission: 'reviews.view');
$this->cms()->admin()->nav('reviews', ['path' => '/reviews', 'icon' => 'star']);The closure gets the user and the route's params (/reviews/:id gives $params['id']). Pass private: true for a place whose contents stay out of what the assistant is told about the screen on view.
Or ship the same tree as a file, views/reviews.json, with {"path": "/reviews", "permission": "reviews.view", "view": {…}}, and call $this->views() in boot().
Write the words of a view in English, with __() as anywhere in PHP: 'label' => __('Rating'). Each person reads them in their language, from your package's lang/ files. A value from your records, such as a review's title or a field someone named, stays as it was written, so leave it bare. A view shipped as a file is all words, and Lettern translates it for you. ./dev check reports a word in a PHP view left without __().
#What Lettern checks
Before a view reaches the admin, Lettern:
- checks every node against the component catalog and its props, and refuses an unknown component or a misspelled prop;
- leaves out every node with
can(a permission, or a list of them) that the person doesn't hold; - leaves out every action whose ability the person may not run, so no button that would only fail is shown;
- puts every label in the person's language.
Every list, form and button runs its ability as the person looking. Agents don't use views. They run the same abilities directly, over MCP or the API.
#Templates
A string in a view can hold {params.x} (the route), {query.x} (the address), {user.x} (who is signed in) and {row.x} (the row an action or dialog belongs to). A string that is nothing but one reference stands for the value itself, such as an object or a list. Write {{word}} for the words {word} themselves. Ability input left empty after filling in is not sent, so an unchosen filter doesn't narrow anything.
#Components
| Component | What it draws |
|---|---|
ui/page | The bar that names the place (title, icon), how many it holds (count, from an ability), the list it belongs to (back), its actions, and width (narrow, default, wide, full). |
ui/tabs, ui/tab | A page's tabs, each with an id, a label, a count and actions of its own. A plugin that keeps its settings with this place gets its Settings tab after them. |
ui/section | A titled group, with actions. |
ui/stack, ui/grid | One after another, or side by side in columns. |
ui/records | Records from an ability: a table with search, filters, pages, a row that opens, row actions and bulk actions. |
ui/form | Fields loaded from one ability and saved with another, with the save bar. |
ui/settings | A settings page's form, by namespace. |
ui/detail | Labelled values (items), or every value of a record, with its raw data to unfold. |
ui/stat, ui/chart | A metric with its trend, a line or bar chart: literal, or from a source. |
ui/notice | A line that needs attention, with one action. |
ui/diff | Before and after, key by key. |
ui/preview | A page of the site, at url. |
ui/actions | A row of buttons. |
ui/text, ui/empty | Words, or nothing there yet, with one action. |
ui/redirect | Sends whoever opens the place to where it is now. |
widget/<id> | A Dashboard widget. |
Your own components are in the catalog too (see Generative UI). Use one in a view for what the catalog doesn't draw, such as an editor, and keep the place around it as data.
#ui/records
['type' => 'ui/records', 'props' => [
'label' => __('Orders'),
'source' => ['ability' => 'orders/list', 'input' => ['status' => '{query.status}']],
'at' => 'items', // where the rows are in the response; a list is the rows itself
'key' => 'id',
'columns' => [['key' => 'customer.name', 'label' => __('Customer'), 'muted' => 'customer.email']],
'search' => ['param' => 'search', 'placeholder' => __('Search orders…')],
'filters' => [['param' => 'status', 'label' => __('Status'), 'all' => __('All Orders'), 'options' => [['value' => 'paid', 'label' => __('Paid')]]]],
'address' => true, // search, filters and page in the address: linkable, and kept on Back
'paged' => ['perPage' => 50],
'open' => ['label' => __('Open'), 'to' => '/orders/{row.id}'],
'actions' => [],
'bulk' => [['label' => __('Archive'), 'ability' => 'orders/archive', 'input' => ['ids' => '{ids}']]],
'empty' => ['icon' => 'receipt', 'title' => __('No orders yet')],
]]- Search: with a
param, the ability searches. Without one, a list loaded whole is searched in the browser.search: falseoffers none, anddisplay: falsemakes it a list read as it is (a ranked top 8): no Display menu or sorting. - Filters: a filter's options are listed, or come from the response:
atis where they are,valueandtextname their keys. - Pages:
pagedsendspageandperPage, and readspagesortotalfrom the response. - Bulk actions: they make the rows selectable, and each one gets the selected rows'
{ids}. - Empty:
emptywith anicon, atitle, atextand anaction, orsize: "sm"for one quiet line inside a section.
A column's format writes its value: text, strong, muted, code, badge and badges, status (a dot and a word), date, datetime, ago, number, money (in cents, with currency), bytes, share (a bar for a part of the whole, 0 to 1), compact (1.2K), yesno, person, count, image and link. tones and labels give a value its color and its words.
More of a column:
mutednames a second value drawn quieter after the first, andwarninga problem drawn in its place.iconnames the row's own icon, andemptywhat an empty value reads as.image: truegives the list a grid of cards.format: switchwith anactiondraws a switch that runs the action with{row}and the new{value}.ariaLabelnames it for each row, anddisablednames a key of the row that locks it.
#ui/form
['type' => 'ui/form', 'props' => [
'source' => ['ability' => 'reviews/settings'], // returns {fields, values}; or give `fields` here
'save' => ['ability' => 'reviews/configure', 'as' => 'values'],
'after' => 'manifest',
]]The values are saved under as (values unless you name another key), or spread over the input with as: "*". Use into for a list edited whole as one field, such as a repeater with display: table. context tells the fields where they are, such as {"locale": "fr"}. ?field=<handle> opens the form at one of its fields.
A component of yours in a tab marks the tab while it has unsaved changes with useTabDirty(dirty) from @letterncms/ui, as a form there does.
#Actions
Buttons, row actions, a row that opens, and a notice's action all take the same shape:
| Key | What it does |
|---|---|
ability, input | Runs the ability. success is said once it worked. then is refresh, manifest, back or open (opens to with {result}), or a list of them. |
form | Asks first: fields in a dialog. Without input, their values are the input; with one, write them in as {values.x}. A refusal shows on its fields, and the dialog stays open. |
copy | Copies a template to the clipboard. |
confirm | Asks first: title, text, confirm, danger. |
to, href | Goes to a place of the admin, or opens a link in a new tab. |
dialog | Opens a dialog of nodes (title, text, body, actions), drawn with the row it was opened for. |
component | Opens one of your catalog components with props. It gets close and scope. |
when, unless, can | Shows the action only when a template holds or doesn't, or for a permission. |
disabled | Shows it, but not possible while a template holds. In a page's bar, {dirty} holds while the tab on view has unsaved changes. |
name | Opens it from the address too: /reviews?do=new-review. |
After an ability runs, everything on view loads again.
#Add to another view
Every view passes through the view.<id> filter, with the user and the params. Settings pages are the view settings with ['namespace' => …], and SettingsModule::withTab() and withAction() add to one:
use Cms\Modules\Settings\SettingsModule;
$this->cms()->hooks()->addFilter('view.settings', fn (array $view, User $user, array $params) => $params['namespace'] !== 'reviews' ? $view
: SettingsModule::withAction($view, ['label' => __('Send Test'), 'icon' => 'mail', 'ability' => 'reviews/test', 'input' => ['to' => '{user.email}'], 'success' => __('Sent to {user.email}')]));The views/get ability returns a view as the admin draws it, for the person signed in.