Extend Pluto

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

Every page of the admin uses three components: the lists, the forms, Media, Settings, Users. Use them for your own admin pages too:

  • PlutoViewAdminPanel. The outer shell. It has the slots toolbar, default, aside, left (a left panel), and rail (a column of icon buttons on desktop, for the left panel and other actions of the view). The props are width (container or full), fill, leftTitle, and panelToggles. The models are v-model:aside-open and v-model:left-open.
  • PlutoViewToolbar. One row with an optional back button, an icon, a title, and button groups. Give it the icon of the menu item of the page.
  • usePlutoBackClose(open, enabled?). For your own overlay on a narrow screen (a slideover, a panel): while it is open, the Back button or the back swipe of a phone closes it, and the page stays. Pass the narrow-screen rule as enabled.
  • 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. The Back button of a phone closes it, and the page stays.

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.

An admin page follows these rules, so it looks like the pages of Pluto:

  • The title is in the toolbar, with the icon of the page. The page has no large heading. The actions of the page go at the right of the toolbar.
  • The tabs of a section go under the toolbar (PlutoAdminNavTabs in the toolbar slot). A tab does not repeat its name as a heading. A short intro in muted text can open the tab.
  • Each part of the page is a UCard: a title and a short description in the header, the content in the body.
  • Buttons take their natural width.
  • A link to another site ends with the ↗ icon of Nuxt UI (i-lucide-arrow-up-right): trailing-icon on a button, and <PlutoExternalLink to="https://…"> for a link in a sentence. A menu item that opens a new tab gets the icon from Nuxt UI.
  • Every icon-only button has a tooltip that says what it does, with the same text as its aria-label: <UTooltip :text="t('my-layer.refresh')"><UButton icon="i-lucide-refresh-cw" :aria-label="t('my-layer.refresh')" /></UTooltip>. For the button of a menu or a popover, put the tooltip inside the menu, around the button.
app/pages/admin/reports.vue
<template>
  <PlutoViewAdminPanel>
    <template #toolbar>
      <PlutoViewToolbar title="Reports" icon="lucide:chart-bar">
        <template #right>
          <UButton label="Export" icon="lucide:download" color="neutral" variant="ghost" />
        </template>
      </PlutoViewToolbar>
    </template>

    <UCard>
      <template #header>
        <h2 class="font-semibold">This month</h2>
        <p class="text-muted text-sm">The orders of the last 30 days.</p>
      </template>
    </UCard>
  </PlutoViewAdminPanel>
</template>

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.

Search, filters, and sort

Above the rows, the list has a search box and filters.

  • The search finds items by their title. The list changes 300 ms after you stop typing.
  • A type with a publish workflow has a Status filter.
  • A reference field with a target (for example tags, categories, or authors) has a filter. The filter menu has a search.
  • A select field and a boolean field have a filter when they show in the list (inList). Set filter: true on the field to add the filter, or filter: false to remove it.
  • A set filter shows its label and its value, for example "Categories: Travel".
  • Clear the filters removes the search and the filters. The sort stays.
  • Click the header of a column to sort: ascending, then descending, then the default sort of the type. A list field of type text, textarea, slug, number, select, boolean, or date can sort. Set sortable: false on a field to stop it.
  • The URL keeps the search, the filters, and the sort. A reload, a link, and the Back button show the same list.
  • When no item matches, the list shows "No posts match" and Clear the filters.

A type with no items shows no search and no filters.

Bulk actions

On a wide screen, each row has a checkbox. The checkbox in the header selects all the loaded rows.

When you select rows, a bar replaces the filters:

  • Publish publishes the selected items that are not published.
  • Unpublish changes the selected published items back to the default status.
  • Remove removes the selected items. The list asks you to confirm. For a type with a trash, the button is Move to trash (see The trash).
  • Clear the selection removes the selection.

Publish and Unpublish need the publish capability of the type. Remove needs the delete capability.

Pluto sends one request for each item, four at a time. Each request goes through the normal routes, so the same permissions, hooks, and history apply. On the Git CMS, each item is one commit. At the end, a message shows how many items changed and how many failed. A new search, filter, or sort removes the selection.

The trash

A type with trash: true keeps a removed item in a trash for 30 days. Then Pluto deletes it forever. A type without trash deletes the item at once.

  • Move to trash asks you to confirm, then moves the item. The message has an Undo button. This is the same in the list, in the bulk bar, and in the edit form.
  • The list has two tabs: All and Trash. The Trash tab shows the number of items in the trash.
  • In the Trash tab, Restore moves an item back to the list. Delete forever deletes it. You can select more items and do the same for all of them. Empty the trash deletes every item in the trash.
  • Delete forever and Empty the trash ask you to confirm. You cannot undo them.
  • The Trash tab, Restore, and Delete forever need the delete capability of the type.

An item in the trash is not in the list, on the site, or in the public API. It keeps its id, and the links from other items stay. When Pluto deletes it forever, the links go too. A restore can fail when another item has the same slug: change the slug of that item, then restore again.

Pluto deletes the items that are in the trash for more than 30 days in two ways:

  • A scheduled task, pluto:trash:purge, runs every day at 03:00. A Node, Bun, or Deno server and Cloudflare run it. Vercel, Netlify, and a static site do not.
  • When a user opens the Trash tab.

So every host deletes the old items. See Trash for the adapter contract.

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.

Groups in the aside

The aside can put fields in collapsible groups. List the groups in sideGroups of the type, and give a field the group of its group:

defineContentType({
  name: 'event',
  sideGroups: [
    { id: 'venue', label: 'Venue', icon: 'lucide:map-pin', order: 10 },
    { id: 'tickets', label: 'Tickets', defaultOpen: false },
  ],
  fields: [
    { name: 'city', type: 'text', label: 'City', region: 'side', group: 'venue' },
    { name: 'price', type: 'number', label: 'Price', region: 'side', group: 'tickets' },
  ],
})
  • Fields with no group show first, with no header. A field with a group id that the type does not have counts as no group.
  • A group shows only when it has fields. Groups sort by order (default 100).
  • defaultOpen sets the state when the form loads. The default is true.
  • label is a translation key or plain text.
  • The groups also apply to the tab of a canvas form.

A layer that adds fields to a type adds its group in the same content type extension. The SEO add-on adds its fields in an SEO group, closed when the form loads.

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'
  backup: false       // default true
}
  • 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.
  • backup turns the backup of unsaved changes on or off for the type. The default is true. The form keeps the backup in the browser, and on a server when the adapter supports it. See Unsaved changes and backups. Set it to false for a type whose text must not stay in the browser. There is no setting for the whole app.

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
DraftSave draft, Schedule, and Publish
In reviewSave, Schedule, and Publish, and Return to draft in the overflow menu
ScheduledSave, Reschedule, and Publish now, and Unschedule in the overflow menu
PublishedUpdate, and Unpublish in the overflow menu

Schedule shows only for a type with status.schedule. A user without the publish capability sees Submit for review in place of Schedule and Publish (a type with status.review).

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.

Scheduled publish

A type with status.schedule can publish an item at a date and time. See Scheduled publish and review for the options.

  • Schedule opens a dialog with a date and a time. The time is in the time zone of your browser. The dialog shows the time zone. You cannot pick a time in the past.
  • A scheduled item has a blue badge with the date, in the form and in the list. The Status filter of the list has Scheduled.
  • Reschedule changes the time. Publish now publishes the item at once. Unschedule makes the item a draft again.
  • At the time, the item is on the site. A server read shows it at once. A scheduled task, pluto:publish:scheduled, then sets the status to published. It runs every 5 minutes on a Node, Bun, or Deno server and on Cloudflare. On Vercel and Netlify, the item is on the site at its time, and the status changes when a user who can publish opens the list. A static site on GitHub Pages needs a new build: the GitHub Pages workflow does it every hour.
  • Publish and Unpublish in the bulk bar also work on scheduled items.

Pending review

A type with status.review lets a user write without publishing. This user has the write capability of the type, but not the publish capability.

  • The user saves drafts and clicks Submit for review. The item gets an amber In review badge.
  • A user who can publish opens the item. This user can Publish it, Schedule it, or use Return to draft.
  • A user without the publish capability cannot change a published or a scheduled item. The form shows a message and no save button.

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.