Skip to content
Plugins

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():

PHP
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

ComponentWhat it draws
ui/pageThe 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/tabA 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/sectionA titled group, with actions.
ui/stack, ui/gridOne after another, or side by side in columns.
ui/recordsRecords from an ability: a table with search, filters, pages, a row that opens, row actions and bulk actions.
ui/formFields loaded from one ability and saved with another, with the save bar.
ui/settingsA settings page's form, by namespace.
ui/detailLabelled values (items), or every value of a record, with its raw data to unfold.
ui/stat, ui/chartA metric with its trend, a line or bar chart: literal, or from a source.
ui/noticeA line that needs attention, with one action.
ui/diffBefore and after, key by key.
ui/previewA page of the site, at url.
ui/actionsA row of buttons.
ui/text, ui/emptyWords, or nothing there yet, with one action.
ui/redirectSends 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

PHP
['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: false offers none, and display: false makes 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: at is where they are, value and text name their keys.
  • Pages: paged sends page and perPage, and reads pages or total from the response.
  • Bulk actions: they make the rows selectable, and each one gets the selected rows' {ids}.
  • Empty: empty with an icon, a title, a text and an action, or size: "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:

  • muted names a second value drawn quieter after the first, and warning a problem drawn in its place.
  • icon names the row's own icon, and empty what an empty value reads as.
  • image: true gives the list a grid of cards.
  • format: switch with an action draws a switch that runs the action with {row} and the new {value}. ariaLabel names it for each row, and disabled names a key of the row that locks it.

#ui/form

PHP
['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:

KeyWhat it does
ability, inputRuns the ability. success is said once it worked. then is refresh, manifest, back or open (opens to with {result}), or a list of them.
formAsks 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.
copyCopies a template to the clipboard.
confirmAsks first: title, text, confirm, danger.
to, hrefGoes to a place of the admin, or opens a link in a new tab.
dialogOpens a dialog of nodes (title, text, body, actions), drawn with the row it was opened for.
componentOpens one of your catalog components with props. It gets close and scope.
when, unless, canShows the action only when a template holds or doesn't, or for a permission.
disabledShows it, but not possible while a template holds. In a page's bar, {dirty} holds while the tab on view has unsaved changes.
nameOpens 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:

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