Core

Lists and forms

Learn how the generic list and form pages of a content type work.

Core makes a list page and a form for each content type. A layer does not need to write them.

The generated pages

For a content type with autoRoutes not set to false, core adds these pages:

PageComponent
/admin/content/<type>PlutoContentList
/admin/content/<type>/newPlutoContentForm
/admin/content/<type>/<id>PlutoContentForm

The edit page is a catch-all, so the <id> can hold slashes, for example 1.start/2.install for a nested type. The default edit link is <basePath>/ plus contentIdPath(id).

Core also adds one sidebar entry for the type, with the children "All" and "Create new".

A layer with its own URLs can set basePath, newPath, and editPath on the type, and render the components itself:

<PlutoContentList type="product" />
<PlutoContentForm type="product" />
<PlutoContentForm type="product" :id="route.params.id" />

The view shell

PlutoContentList and PlutoContentForm use three components for one page:

  • PlutoViewAdminPanel. The outer shell. It has a toolbar slot, a main slot, and an aside slot. The props are width (container or full), asideOpen, and fill.
  • PlutoViewToolbar. One row with an optional back button, a title, and button groups.
  • PlutoViewAside. A right panel with a v-model:open. On a screen of lg (64rem) and wider, it is a plain aside. On a narrow screen, it is a slideover.

On a wide screen, the user can drag the aside to resize it between 240px and 560px. Pluto stores the width in the pluto-view-aside-width cookie.

These per-page components are not the same as the global admin layout. The layout owns the left sidebar and the page frame. The view shell gives one page its own toolbar and aside.

The list

The list shows UTable on desktop and cards on mobile.

  • It loads 50 rows at a time and loads more near the end of the scroll.
  • The table is virtualized and has a sticky header.
  • The table is as tall as its rows. A long list scrolls inside the table.
  • The title links to the edit page. An item with no title shows "Untitled".
  • Edit and Remove show when you point at a row. On a touch screen, they stay visible.
  • A footer shows "Showing N of M".
  • While the first page loads, the list shows its shape: the table with the column headers, or the cards.
  • When the list has no rows, it shows an error with Try again, or an empty state with a New button. The button shows only if the user can write.

A field with inList shows in the list. Use listFormat to change the text of a cell, for example to format a price.

The form

The form has two columns. Fields with region: 'side' go in the aside. The others go in the main column. Both sort by order.

The form validates with validateContentPayload before it saves. The server validates again.

On create, the form shows a toast and goes to the edit URL. On update, it shows a toast and stays. If an update changes the id of the item (for example, an adapter renames a file), the form goes to the new edit URL.

In edit mode, the heading and the tab title show the titleField of the item.

A save sends the _version token of the item back, when the adapter gives one. If the token is stale, the form shows "This item changed since you opened it."

If the item fails to load, the form shows an empty state. It has a Try again button, except for a 404.

Form options

Set form on the content type:

form: {
  layout: 'focus',    // 'default', 'focus' or 'canvas'
  aside: true,        // true, false, or 'auto'
  asideCollapsed: true,
  width: 'full'       // 'container' or 'full'
}
  • layout: 'focus' drops the page heading and moves the type label into the toolbar. The aside starts closed. Use it for a writing surface.
  • aside: 'auto' is the default. The aside shows when any field has region: 'side'.
  • layout: 'canvas' with canvasField puts one field in the whole main region, between a left panel and a right panel with tabs. The other fields go in a tab with the label of the type (for example Post or Page). The page editor of the docs layer and the post editor use it.
  • leftPanel (canvas only) tells core that the widget of the canvas field adds a left panel. Core shows a skeleton of the panel while the page and the widget load. Give a label, or { label, class } to also match the background of the real panel.

Below the lg breakpoint, the default layout puts the aside fields inline, in a "Settings" section. A required field never hides behind a button. The focus layout keeps the slideover.

The open or closed state of the aside persists per content type, in the cookie pluto-content-aside-<type>.

The publish workflow

A type opts in with status. The form then changes its buttons:

StateButtons
No statusSave
Not publishedSave draft and Publish
PublishedUpdate, and Unpublish in the overflow menu

The form always sends the status in payload.status. The adapter maps it to the right column.

The publish button needs capabilities.publish. If a type does not set it, the form uses capabilities.write.

Content actions

A layer adds its own button to the form with the contentActions registry field:

definePlutoExtension({
  id: 'my-layer',
  contentActions: [
    {
      id: 'sync-inventory',
      contentType: 'product',
      label: 'Sync inventory',
      icon: 'lucide:refresh-cw',
      placement: 'toolbar',
      show: ctx => ctx.editing,
      onSelect: async (ctx) => {
        await syncInventory(ctx.item)
      }
    }
  ]
})
  • contentType is a type name, or '*' for every type.
  • placement is toolbar or aside. The default is toolbar.
  • show is optional. Without it, the action always shows.
  • onSelect gets type, item, editing, saving, save, and remove. save and remove use the logic of the form, including the delete confirmation.

Toolbar items

A layer adds a component, such as a status or an indicator, to the toolbar of the form with the contentToolbarItems registry field. The toolbar shows it after the View link and before the buttons.

definePlutoExtension({
  id: 'my-layer',
  contentToolbarItems: [
    {
      id: 'sync-state',
      contentType: '*',
      order: 50,
      component: defineAsyncComponent(() => import('./SyncState.vue')),
    },
  ],
})
  • contentType is a type name, or '*' for every type.
  • The component gets no props. It calls usePlutoContentForm() to read the item and to run code after a save (onSaved).
  • The component shows nothing when it has nothing to say. The entry has no show test.
  • Load the component with defineAsyncComponent. See Lazy loading.

The Git CMS uses this for its build status.

Field widgets in the form

PlutoContentField picks the widget for a field. If no widget matches, it shows the label and the message No widget registered for field type "<type>". See Add a field widget.