Block extensions mechanism#

A common pattern in blocks is the "variations" pattern - a slightly different versions of a block that can be toggled on demand by the editors. Choosing the listing template (gallery, summary listing, etc.) for the Listing block is one example of the typical use cases for this feature.

A block can define variations in the block configuration. These variations can be used to enhance or complement the default behavior of a block without having to shadow its stock components. These enhancements can be at the settings level (add or remove block settings) via schema enhancers or, if the code of your block allows it, even use alternative renderers (e.g., in view mode) showing the enhanced fields or modifying the block behavior or look and feel.

Note

The Listing block already supports several of them (only in the "template" or the component seen on view mode), and can be extended, although it still does not use the final specification on how to define them in the configuration, (that will change in next Volto versions). The rest of the stock Volto blocks will also follow to support variations by default.

While it is up to each specific block implementation on how they use this machinery, Volto provides the infrastructure to help define block extensions and variations.

Block variations#

Volto ships with a default extension mechanism for blocks, named "variation". It is advisable to use this extension point for the typical use case of "alternative view template for the block".

A block can define variations in the block configuration. These variations can be used to enhance or complement the default behavior of a block without having to shadow its stock components. These enhancements can be at the settings level (add or remove block settings) via schema enhancers or, if the code of your block allows it, even use alternative renderers (e.g., in view mode) showing the enhanced fields or modifying the block behavior or look and feel.

If you use schema-based forms to edit the block's data, use the BlockDataForm component instead of the InlineForm. The BlockDataForm component will automatically inject a "variation" select dropdown into the form (if any defined), allowing editors to choose the desired block variation.

This is how the configuration would like for an imaginary block:

export default (config) => {
  config.blocks.blocksConfig.teaserBlock.variations = [
    {
      id: 'default',
      title: 'Default',
      isDefault: true,
      template: SimpleTeaserView,
    },
    {
      id: 'card',
      label: 'Card',
      template: CardTeaserView,
      schemaEnhancer: ({ schema, formData, intl }) => {
        schema.properties.cardSize = '...'; // fill in your implementation
        return schema;
      },
    },
  ];
};

Notice the schemaEnhancer field, which allows customization of the schema for schema-based blocks, when a particular variation is chosen.

To get the same behavior for any other custom extension, you can wrap InlineForm in the withBlockSchemaEnhancer HOC:

import { defineMessages } from 'react-intl';

const GalleryBlockForm = withBlockSchemaEnhancer(
  InlineForm,
  'galleryTemplates',
);

You can even wrap BlockDataForm with it and "stack" multiple block extensions selection dropdowns.

Schema enhancers#

In addition to the select dropdown, the withBlockSchemaEnhancer also provides a schema enhancement mechanism. Any registered extension plugin can provide a schemaEnhancer function that can tweak the schema to be used by the InlinForm component. This function receives an object with formData, which is the block data, schema - the original schema that we want to tweak and the injected intl, to aid with internationalization.

For example:

const messages = defineMessages({
  title: {
    id: 'Column renderer',
    defaultMessage: 'Column renderer',
  },
});

export default (config) => {
  config.blocks.blocksConfig.dataTable.extensions = {
    ...config.blocks.blocksConfig.dataTable.extensions,
    columnRenderers: {
      title: messages.title,
      items: [
        {
          id: 'default',
          title: 'Default',
          isDefault: true,
          template: DefaultColumnRenderer,
        },
        {
          id: 'number',
          title: 'Number',
          template: NumberColumnRenderer,
        },
        {
          id: 'colored',
          title: 'Colored',
          template: ColoredColumnRenderer,
          schemaEnhancer: ({ formData, schema, intl }) => {
            schema.properties.color = {
              widget: 'color',
              title: 'Color',
            };
            schema.fieldsets[0].fields.push('color');
            return schema;
          },
        },
      ],
    },
  };
};

Note

The schemaEnhancer is a generic extension mechanism provided by withBlockSchemaEnhancer. The BlockDataForm component already integrates it for the variation extension.

Volto provides a helper to combine multiple schemaEnhancer functions into a single function. This allows creating clean, single purpose, reusable schema enhancers:

import { composeSchema } from '@plone/volto/helpers';

const oldEnhancer = blocksConfig.dataTable.schemaEnhancer;

blocksConfig.dataTable.schemaEnhancer = composeSchema(
  oldEnhancer,
  addTitleField,
  addStandardStyling,
);

Conditional variations#

Apart from the form data and the schema, the schema enhancers will also get passed the navigation root and content type. These values can be used to dynamically change the variations to be used by the block.

For example:

import { defineMessages } from 'react-intl';
import { addExtensionFieldToSchema } from '@plone/volto/helpers/Extensions';

const messages = defineMessages({
  variation: {
    id: 'Variation',
    defaultMessage: 'Variation',
  },
});

export const conditionalVariationsSchemaEnhancer = ({
  schema,
  formData,
  intl,
  navRoot,
  contentType,
}) => {
  if (contentType === 'Event' || navRoot.id === 'my-nav-root') {
    // We redefine the variations in the case that it's an Event content type
    const variations = [
      {
        id: 'default',
        title: 'Default',
        isDefault: true,
      },
      {
        id: 'custom',
        title: 'Custom modified variation',
      },
    ];

    schema = addExtensionFieldToSchema({
      schema,
      name: 'variation',
      items: variations,
      intl,
      title: messages.variation,
    });
  }
  return schema;
};

Consuming the extensions#

It is completely up to the block implementation on what exactly is an "extension". The typical use case is to make parts of the view renderer "replaceable". If used with the withBlockSchemaEnhancer-derived forms, the chosen extension is saved in the block data, but in the rendering, we will need the "resolved" extension object from the blocksConfig configuration. To help with this, we have another HOC, the withBlockExtensions, which injects the resolved extensions object as props. To use it, wrap your relevant components with it, for example, the block's View component.

const TableBlockView = (props) => {
  const variation = props.variation;
  const Renderer = variation.view;

  return <Renderer {...props} />;
};

export default withBlockExtensions(TableBlockView);