Extend Pluto

Add a field widget

Write a Vue component for a field of a content type.

A field widget is the input that the generic form shows for one field. Core has widgets for most field types. A layer adds a widget for richtext or media, or a special widget for one field.

The contract

Every widget has the same three props and one event:

NameKindMeaning
fieldPropThe field definition. The form passes a translated copy, so the label is ready to show. The copy is made only when the form knows the content type name. Otherwise the field keeps its own text.
modelValuePropThe current value
disabledProptrue when the form is read-only
update:modelValueEventEmit it with the new value
app/components/MyColorField.vue
<script setup lang="ts">
defineProps<{
  field: PlutoField
  modelValue: unknown
  disabled?: boolean
}>()

defineEmits<{ 'update:modelValue': [value: string] }>()
</script>

<template>
  <UFormField :label="field.label" :description="field.description">
    <UInput
      :model-value="String(modelValue ?? '')"
      :disabled="disabled"
      @update:model-value="$emit('update:modelValue', String($event))"
    />
  </UFormField>
</template>

Register the widget

Register the widget with contentFieldWidgets and defineAsyncComponent:

app/plugins/pluto-extension.ts
const MyColorField = defineAsyncComponent(() => import('../components/MyColorField.vue'))

export default defineNuxtPlugin(() => {
  definePlutoExtension({
    id: 'my-layer',
    contentFieldWidgets: [
      { id: 'my-color', widget: 'my-color', component: MyColorField }
    ]
  })
})

Core shows a field skeleton while the chunk loads.

Choose by name or by type

An entry can match in two ways:

  • widget: 'my-color'. A field selects the widget with widget: 'my-color'. This affects only that field.
  • fieldType: 'media'. The widget becomes the default for every field of that type.

Resolution order: an exact field.widget name first. Then the last registered widget whose fieldType matches the field type. Otherwise nothing, and the form shows the message No widget registered for field type "<type>".

Register a widget by widget name when it is for one layer. A fieldType entry becomes the app-wide default for that type, and it can take the fields of other layers.

Core has no richtext widget on purpose. @plutocms/supabase-blog is the default for richtext. Core has the media widget: the picker of the media library. The Git CMS registers a Markdown widget for richtext in CMS mode. The @plutocms/docs layer registers an mdc widget by name. A field selects it with widget: 'mdc'. Core also registers the post-canvas widget by name: the post editor. A field selects it with widget: 'post-canvas'.

Use a widget in a field

{ name: 'color', type: 'text', label: 'Color', widget: 'my-color' }

Share state between widgets

Each widget mounts on its own and cannot read the props of another widget. If a rule spans fields, share the state with useState. supabase-shop does this for the custom, availability, and stock rule. Reset the state when the widgets unmount, so the next form starts clean.

Write pending edits before a save

Some widgets hold edits outside modelValue. For example, an editor can send its value after a delay. A save or a backup that runs in that time would miss the last keystrokes.

The form context has onFlush(callback). The form runs the callbacks synchronously before it builds the payload of a save, and before it writes a backup (also when the page hides). In the callback, emit the pending value now. onFlush returns a function that removes the callback. Vue also removes it when the widget unmounts, if the call is inside setup.

<script setup lang="ts">
const form = usePlutoContentForm()
const editor = useTemplateRef('editor')

form?.onFlush(() => editor.value?.flush())
</script>

usePlutoContentForm() returns null outside the content form. The core widgets send every change at once, so they need nothing.

Add panels in the canvas layout

In the canvas layout, the widget of the canvas field can add panels next to the canvas. See Lists and forms. Put the panel content in PlutoContentCanvasPanel:

<PlutoContentCanvasPanel side="right" id="outline" label="Outline" icon="i-lucide-list-tree">
  <MyOutline />
</PlutoContentCanvasPanel>
  • side="right" adds a tab to the right panel. label and icon are the label and the icon of the tab.
  • side="left" puts the content in the panel left of the canvas. label is the title of the slideover on a narrow screen.
  • id must be unique in the form.
  • usePlutoContentForm().openPanel(id) opens the right panel on the tab with this id.
  • usePlutoContentForm().updateField(name, value) sets another field of the item, with the rules of the form. For example, the post editor sets the title from its own title field.

Outside the canvas layout, the content shows where the panel is. So the widget also works in the other layouts.

The form context also has loading, dirty, errorsByField, onSaved, and panelToggles.

Load your own options

A content field widget is mounted dynamically. An async composable with a top-level await can suspend an already mounted page. Fetch your options in onMounted instead.