Skip to content

Editing custom widgets in context ​

INFO

Be sure to read customizing the user interface first before proceeding with this guide. In this guide we will assume you are already familiar with APOS_DEV=1, ui/apos/components and other concepts covered there.

Most custom widgets, and indeed most core widgets, are edited in a dialog box when the user clicks on the edit button. But there is another possibility: in-context editing on the page. The standard rich text widget is an example of this. The user types text directly on the page.

While editing a schema of fields in the provided dialog box is convenient for developers, sometimes editing directly on the page provides a better user experience. This guide explains how to do that.

WARNING

Editing on the page is not always the best path. Always consider the smallest space in which the widget might be placed when making this decision. Also bear in mind that the appearance of the widget will be different on different devices. The "what you see is what you get" experience can be misleading in some situations.

To implement on-page editing for a custom widget type, we must implement the following pattern:

The basics of contextual editing ​

Module configuration ​

The contextual option of the widget module must be set to true. This is the trigger for contextual editing. Otherwise the normal editing dialog box is displayed for the widget.

The components option of the widget module determines the Vue components to be used for the editing experience. Currently there is just one subproperty, widgetEditor, which is required for contextual editing.

The defaultData option of the widget module may be set to a default value for newly created widgets of this type.

The widget editor ​

The Vue component name configured via the widgetEditor subproperty of the components option (the "widget editor") must display the content of the widget in an editable form. To provide a contextual editing experience, that interface should be similar to the ordinary read-only view generated by widget.html.

The widget editor receives a modelValue Vue prop containing the existing value of the widget (an object), and emits an update Vue event with a new object whenever appropriate. The component should not modify the modelValue prop given to it.

The widgetEditor must not attempt to save changes by itself. Instead it must emit the update event and let Apostrophe takes care of the rest. Never assume the widget will be in a particular document type. It may be in any area, nested in any document type. Apostrophe will handle this for you.

Debouncing update events ​

If the value will be changing quickly, for instance as the user types, performance can suffer if an update event is generated for every keystroke. The widget editor can speed up the interface by emitting an update event no more than once per second. This is called "debouncing."

As a hint that input might not yet be saved, a widget that "debounces" update events should emit a context-editing event on every change, even if update is intentionally delayed. However this should only be done if the docId prop passed to the widget editor is equal to windows.apos.adminBar.contextId.

If the focused prop of the widget editor becomes false and an update event has been delayed for debouncing purposes, the widget should cancel its timer and immediately emit the update. Use Vue's watch feature to monitor focused.

Saving the content ​

By default the fields section of a contextually edited widget has no effect on the user interface. Emitting a reasonable value is the task of the custom widget editor component. However configuring fields is still necessary since the server will still use them to sanitize the data before saving it.

If no fields section is configured, no data will be saved at all unless the sanitize method of the module is overridden to provide an alternative way to verify user input, as is done in the case of the core rich text widget.

Example ​

Here is a simple "hero" heading widget. Both the heading and the width of the heading can be adjusted directly on the page. This gives the user immediate visual feedback.

js
module.exports = {
  extend: '@apostrophecms/widget-type',
  options: {
    contextual: true,
    defaultData: {
      heading: '',
      width: 100
    },
    components: {
      widgetEditor: 'HeroWidgetEditor'
    }
  },
  fields: {
    add: {
      heading: {
        type: 'string',
        def: ''
      },
      width: {
        type: 'integer',
        min: 10,
        max: 100,
        def: 100
      }
    }
  }
};
modules/hero-widget/index.js

What is happening here?

This index.js file configures the hero-widget module. The contextual: true option enables contextual editing, defaultData provides a default value that satisfies the schema, and components configures the Vue component name to be used for editing.

The fields section provides a schema of fields to sanitize and store the data in the widget. Even though the interface does not use a traditional form, this is still a convenient way to verify the content meets expectations on the server side. Remember: it is never OK for the server to trust the browser.

WARNING

Don't forget to also enable the module in app.js, like any other module.

nunjucks
<h1 class="contextual-heading" style="width: {{ data.widget.width }}%">
  {{ data.widget.heading }}
</h1>
modules/hero-widget/views/widget.html

What is happening here?

This is a straightforward widget.html template, as might be found in any custom widget. The presentation is intentionally similar to that used in the widget editor component, below.

js
<template>
  <div :class="{ focused }">
    <input
      class="contextual-heading"
      type="text"
      v-model="heading"
      placeholder="Heading"
      :style="style"
    />
    <div class="contextual-heading-controls">
      <button
        :class="buttonClasses(25)"
        @click.stop.prevent="setWidth(25)"
      >25%</button>
      <button
        :class="buttonClasses(50)"
        @click.stop.prevent="setWidth(50)"
      >50%</button>
      <button
        :class="buttonClasses(75)"
        @click.stop.prevent="setWidth(75)"
      >75%</button>
      <button
        :class="buttonClasses(100)"
        @click.stop.prevent="setWidth(100)"
      >100%</button>
    </div>
  </div>
</template>

<script>

export default {
  name: 'HeroWidgetEditor',
  props: {
    type: {
      type: String,
      required: true
    },
    options: {
      type: Object,
      required: true
    },
    modelValue: {
      type: Object,
      default() {
        return {};
      }
    },
    docId: {
      type: String,
      required: false,
      default() {
        return null;
      }
    },
    focused: {
      type: Boolean,
      default: false
    }
  },
  emits: [ 'update' ],
  data() {
    return {
      heading: this.modelValue.heading,
      width: this.modelValue.width
    };
  },
  watch: {
    heading() {
      this.emitUpdate();
    },
    width() {
      this.emitUpdate();
    }
  },
  computed: {
    style() {
      return {
        width: `${this.width}%`
      };
    }
  },
  methods: {
    emitUpdate() {
      this.$emit('update', {
        ...this.modelValue,
        heading: this.heading,
        width: this.width
      });
    },
    setWidth(n) {
      this.width = n;
    },
    buttonClasses(width) {
      return {
        [`width-${width}`]: true,
        active: this.width === width
      };
    }
  }
};
</script>

<style lang="scss" scoped>
  .contextual-heading-controls {
    display: none;
    text-align: center;
  }
  .focused .contextual-heading-controls {
    display: block;
  }
  .contextual-heading-controls button {
    font-size: 10px;
  }
  button.active {
    background-color: red;
    color: white;
  }
  .width-25 {
    width: 36px;
    height: 24px;
  }
  .width-50 {
    width: 72px;
    height: 24px;
  }
  .width-75 {
    width: 96px;
    height: 24px;
  }
  .width-100 {
    width: 144px;
    height: 24px;
  }
</style>
modules/hero-widget/ui/apos/components/HeroWidgetEditor.vue

What is happening here?

This is HeroWidgetEditor, the custom widget editor Vue component for this widget type. The focused prop is used to decide whether to show the resizing buttons for the hero heading, so they don't clutter the page when the user is not editing this widget. When the user changes either the width or the heading text, the widget editor emits an update event with a new object containing the width and heading fields, in addition to any pre-existing properties of the original modelValue prop.

WARNING

When emitting an update, be sure to include any properties of modelValue that your editor does not directly edit, such as type. Otherwise a JavaScript error will occur. The easiest way to do this is with ...this.modelValue, as shown here.

css
.contextual-heading {
  display: block;
  color: white;
  background-color: red;
  padding: 24px;
  border: 1px solid #f88;
  font-size: 24px;
  text-align: center;
  margin: auto;
  box-sizing: border-box;
}
modules/hero-widget/ui/src/index.scss

What is happening here?

Last but not least, we still need styles for the widget's normal appearance when it is not being edited. A ui/src/index.scss file for the hero-widget module is a good place to do that and will automatically be loaded all the time, unlike the admin UI components in ui/apos.