Drupal: How to Configure Smart Editorial Control of Migrated Content

Chris Hill
8 min readMar 28, 2024

--

How to maintain data integrity and allow granular editorial control of continuously-imported content in Drupal.

In content management systems, we want both control and flexibility. In this post, I’ll tell you how my team got both. In our case, we wanted to continually sync records into Drupal from an external data source for display on our website. We wanted to lock down parts of those imported records from being edited in Drupal, but allow other parts to be edited. The goals were to both maintain data integrity* and provide an easy editorial experience.

* The restrictions described in this post relate only to the form fields. To protect data at a lower level, check out Drupal’s Entity Validation API.

Part 1: The goals

We wanted to sync information about events (e.g., webinars, in-person events, etc.) from an external data source (an event-management platform) into Drupal, with these additional requirements:

  1. An editor cannot change any imported events in Drupal. We configured Drupal to both import new records and update existing records. If someone manually changed an imported event, then this change would be overwritten the next time Drupal pulled updates from the external data source.
  2. However, imported events are incomplete. That is, there are certain fields on the Drupal event which aren’t imported, because they have no corresponding fields in the external data source. We need to allow the editor to add these unmapped fields (but not change any of the imported fields).
  3. Editors might need to create new events in Drupal from scratch. For example, they might want to use the Drupal website to advertise an event, but that event is not in the external data source.
  4. In rare circumstances, we need to unlock all fields, even on imported events, so that we can manually override imported fields.

The events will be imported on a regular basis, e.g., daily. When an event is updated in the external data source, Drupal should pull those same updates into its imported event. Because of this, we cannot allow editors in Drupal to change data in imported fields. Otherwise, their changes would be overwritten by a later import.

Part 2: The implementation

We achieved these goals with three features:

  1. A Drupal migration, to import events from the external data source.
  2. A new Drupal form mode, to lock certain fields for imported events.
  3. Some custom code to glue this logic together.

Let’s walk through an example scenario, including working custom code. (You can find working custom code and screenshots at the end of this post.)

A Drupal migration

To import events into Drupal, we first defined a content type in Drupal for events, named “Event”. Then, we defined a migration using Drupal’s Migrate module.

Here are the fields for the content type:

  • Title
  • Description
  • Purpose
  • Audience
  • More information
  • Date and time (start and end)
  • Tags
  • Image

Describing how to build a migration is outside the scope of this post. But you can check out many other good resources for that, including Drupal’s official docs.

For the migration, here’s a basic mapping of the Drupal Event content type to its external data source (pardon the ASCII table; Medium doesn’t support real tables):

+-----------------------------+---------------------------+-----------------+
| External data source field | Drupal Event field | Migration notes |
| | | |
| Name | Title | |
| Body | Description | |
| (none) | Purpose | Not mapped |
| (none) | Audience | Not mapped |
| (none) | More information | Not mapped |
| Date | Date+time (start and end) | |
| Categories | Tags | |
| Picture | Image | |
+-----------------------------+---------------------------+-----------------+

Notice the three unmapped fields? The external source doesn’t provide each event’s purpose, audience, or more information. So we will allow Drupal users to manually edit those fields on imported events.

This is our desired workflow:

  1. Drupal imports an event from the external data source.
  2. An editor logs into Drupal and reviews the event.
  3. They can set the unmapped fields (Purpose, Audience, More information).
  4. They can publish this event now or leave it as a draft (an event is imported as a draft, so that a human must review it in Drupal before it’s published).

Remember these important editorial restrictions:

  • An editor should not be allowed to change any of the mapped fields (e.g., they can’t change the title, event date+time, etc.).
  • These restricted fields should still appear on the event form, even though the editor is not allowed to change them. I.e., they should appear in read-only mode.

How can we achieve this? Drupal’s form modes to the rescue!

Drupal form modes

Form modes allow you to both rearrange the fields on a form and to change the widgets for each field. For our use case, we’ll define two form modes for the event form:

  1. Default: All fields are editable. This mode should be used when creating an event from scratch, or editing a scratch-created event (i.e., not when editing an imported one). (This form mode comes by default in Drupal, so you don’t need to create it.)
  2. Imported: Only unmapped fields are editable. All imported fields are read-only. This mode should be used for imported events. The read-only feature is provided by the Read-only Field Widget module.

(To learn more about form modes, see these resources: sections 6.8 and 6.9 in the Drupal user guide; this article from Lullabot; and this document from Drupal.org.)

I configured the two form modes like this:

+--------------------------+-----------------------------+----------------------------+
| Field | Widget for Default mode | Widget for Imported mode |
| | | |
| Title | Text field | Read-only |
| Description | Hidden | Read-only |
| Purpose | Long text with wysiwyg | Long text with wysiwyg |
| Audience | Long text with wysiwyg | Long text with wysiwyg |
| Extra info | Long text with wysiwyg | Long text with wysiwyg |
| Date+time (start and end)| Date+time picker | Read-only |
| Tags | Entity reference (multiple) | Read-only |
| Image | File upload | Read-only |
+--------------------------+-----------------------------+----------------------------+

Notice these key differences between the two:

  • Imported: All the fields are read-only, except the unmapped ones. This allows an editor to edit an imported event, but only change the unmapped fields.
  • Default: All fields are editable.

Custom code

How does Drupal know which form mode to use? We need some custom code to tell Drupal to use Imported mode for imported events and Default mode otherwise.

Drupal provides a hook that we can use for this purpose: hook_entity_form_mode_alter (see Drupal’s API docs, an example, or a blog post about using it).

We’ll need a custom module to implement this hook, which gets fired on each entity form. In the hook, we’ll look up the node in the Events migration, to determine whether this event has been imported or not. If so, then we’ll switch the form mode to Imported (see the end of this post for link to the code).

Remember — every rule is meant to be broken. With this in mind, let’s provide an “escape hatch”, so that we can disable the form mode switcher altogether. This will be helpful when (not if) we have an urgent request to edit an imported node to change some of its imported fields.

Here’s the code to switch form modes, and includes the escape hatch (this would go in file mymodule.module):

<?php

use Drupal\Core\Entity\EntityInterface;

/**
* Implements hook_entity_form_mode_alter().
*/
function mymodule_entity_form_mode_alter(&$form_mode, EntityInterface $entity) {
// Switches imported events to the "imported" form mode,
// which restricts someone from editing imported fields (because their changes
// would be overwritten if source data gets updated).
if ($entity->getEntityTypeId() === 'node' && $entity->bundle() === 'event') {
if (!$entity->isNew()) {
// Provides an "escape hatch" in case you want to disable this form mode
// switcher.
if (!_mymodule_event_can_switch_mode_form()) {
return;
}

if (_mymodule_event_has_been_imported($entity->id())) {
// See mymodule_entity_extra_field_info() and
// mymodule_form_node_event_edit_form_alter() for a visual indicator
// that this view mode has been switched.
$form_mode = 'imported';
}
}
}
}

/**
* Determines whether the events form mode switcher is enabled.
*
* It is enabled by default, but someone can disable it without a code change:
* @code
* drush state:set mymodule.events_form_mode_switcher_enabled 0 --input-format=boolean
* @endcode
*
* @return bool
* Returns true if this feature is enabled (default is enabled).
*
* }
*/
function _mymodule_event_can_switch_mode_form(): bool {
return (bool) \Drupal::state()->get('mymodule.events_form_mode_switcher_enabled', TRUE);
}

/**
* Determines whether a node was imported by the events migration.
*
* @param string $node_id
* The node's ID.
*
* @return bool
* Returns true if this node was migrated as part of migration
* `mymodule_events`.
*
* @throws \Drupal\Component\Plugin\Exception\PluginException
*/
function _mymodule_event_has_been_imported(string $node_id): bool {
$migration_id = 'mymodule_events';

/** @var \Drupal\migrate\Plugin\MigrationPluginManagerInterface $service */
$service = \Drupal::service('plugin.manager.migration');
/** @var \Drupal\migrate\Plugin\MigrationInterface $migration */
$migration = $service->createInstance($migration_id);

if ($migration) {
$migration->getIdMap()->getQualifiedMapTableName();
$source_values = $migration->getIdMap()->lookupSourceId(['nid' => $node_id]);
return count($source_values) > 0;
}

return FALSE;
}

Communicate!

Finally, let’s make it obvious to an editor why you can edit some events but not others. We’ll use Drupal’s “pseudo-fields” API (docs, example) to render a helpful message when someone edits an imported event, so they’ll know why the fields are read-only.

The following code defines a “pseudo-field” and renders it. This field is merely a message, telling the user why the fields are read-only.

You’ll need to place this “field” on both the Default and Imported form modes.

<?php

/**
* Implements hook_entity_extra_field_info().
*/
function mymodule_entity_extra_field_info() {
$extra = [];

$extra['node']['event']['form']['mymodule_form_mode_override_notice'] = [
'label' => t('MyModule: Form mode override notice'),
'description' => t('Alerts the user that this node has been imported, so certain fields may be read-only.'),
'visible' => TRUE,
'weight' => 0,
];

return $extra;
}

/**
* Implements hook_form_FORM_ID_alter().
*/
function mymodule_form_node_event_edit_form_alter(&$form, \Drupal\Core\Form\FormStateInterface $form_state, $form_id) {
// Bail early if our notice is not even configured to display in this form
// mode.
if (!$form_state->get('form_display')->getComponent('mymodule_form_mode_override_notice')) {
return;
}

// We only care about imported nodes.
$form_object = $form_state->getFormObject();
/** @var \Drupal\node\NodeInterface $node */
$node = $form_object->getEntity();
if (!_mymodule_event_has_been_imported($node->id())) {
return;
}

// Is the form mode switcher activated?
if (_mymodule_event_can_switch_mode_form()) {
$message = t('This item has been imported, so some fields cannot be edited.');
}
else {
$message = t('Although this item has been imported, you can edit all fields. Use caution, because your edits could be overwritten by a future import.');
}
$form['mymodule_form_mode_override_notice'] = [
'#markup' => '<p><em><strong>Notice: </strong> ' . $message . '</em></p>',
];
}

Conclusion

Drupal can be an elegant interface for managing content. Using tools like form modes, we can make the editorial experience simpler for our content managers, and maintain data integrity at the same time.

Additional resources

Screenshots

The default form mode (all fields are editable):

Screenshot showing the default form mode (all fields are editable)

The Imported form mode (only non-imported fields are editable):

Screenshot showing the Imported form mode (only non-imported fields are editable).

--

--