Moderate All the Content: Establishing Workflows in Drupal 10
Organizations with established workflows and data oversight protocols typically have firm opinions on how those processes should work, look and feel. Replatforming a website or application to Drupal 10 is an exciting opportunity to rethink aspects of your process and business logic, but sometimes you want to just go with what you know.
In a recent project, Bounteous helped a client replatform several sites. In these older platforms, page content was built URL by URL, subjected to a three-step approval process, and promoted to the live site. The client’s expectation was that every page—and more importantly, every asset on that page—would be individually reviewed and approved.
But how does one moderate embedded assets or shared content in Drupal? Items such as media entities and custom block content can be configured to use a content moderation workflow, but the process is independent of the page nodes on which they’re placed. Client stakeholders—site users responsible for reviewing and approving site content—don’t care about Drupal’s data models or reusable content strategies. Stakeholders want to experience their new or updated content exactly as the end user will.
This is a perfectly reasonable expectation, and it aligns with one of Bounteous’ guiding principles: Quality Assurance testing is a proxy for the customers’ experience: test from the end-user’s perspective. Drupal is a magical Swiss army knife, kept polished and sharp by a robust open source community, so with a bit of clever development and care we can meet this expectation. Improvements in Drupal 10 make this effort even easier going forward.
Basic Workflow Setup
Workflows became part of core in 8.9.x. Within a workflow configuration, one can create moderation states, create transitions between those states, enable Content Moderation Notification and create email rules to be triggered by those transitions.
The notification emails may include tokens relative to the content entity being transitioned from moderation state to state. If our entity is intended to be viewed as an independent page, with a URL, then we can include a link to “/node/XXX/latest” and “please review here” in our notification email to our stakeholder.
If the content being moderated is a shared content type, designed to be embedded and viewed in a different page, we would use a different token: we use business logic and entity query results (inspired in part by contrib module Entity Usage) to discover and render links to every URL on which this shared content is found. This shared content may be media entities, custom block content, and even node content in a View.
The stakeholder visits and reviews the updated content in situ—the context in which the end user will experience the content. To ensure that end users do not see these updates until approved and published, we use revision-handling techniques in our render cycle that, thanks to some newly merged work in Drupal 10, are now more standardized.
An entity, with apologies to Gertrude Stein, is an entity is an entity, yes? Entity API allows us to treat custom blocks, media, and taxonomies as if they were nodes – fieldable, translatable, and revisionable. True, but the methods for handling revisions developed at different rates, and are not uniform from type to type. A patchwork of patches and contrib modules (Media Revisions UI, Block Content Revision UI) had sprung up to handle gaps and inconsistencies.
The road from patch to merge for a standardized revision UI was long and twisty and included assists, encouragement, and reviews from some of Bounteous’ own. The machine name of the revision message text field in media entities is
revision_log_message, while it is
revision_log for nodes and blocks. But today, developers creating their own specific workflow solutions will have a consistent set of methods they can use to get and compare revision ids. Less time on patches and workarounds means more time focusing on their specific challenges.
Suppose we have a media entity with a URL alias of “/download-this-month-data” and a file named “/doc/month_01.xls” in a field. A content editor will create a new draft revision, upload a file named “/doc/month_02.xls” into the field, and save this revision as “Ready for Review.” Our stakeholder is sent a notification and asked to review this change on the homepage where we have a “Call To Action” linking to “/download-this-month-data”.
How can we ensure that, when clicking the link, our stakeholder user downloads “month_02.xls” for review, while our customers, our anonymous users, continue to receive “month_01.xls” until the update is published?
We created a RouteSubscriber that would send ‘entity.media.canonical’ type requests to a custom controller. Our controller checks for revisions and permissions.
If there is a revision more recent than the default revision and if the user has appropriate permissions, then we load that specific revision. The controller writes a new DocumentResponse directly to the file attached to that revision, including appropriate caching and headers. This nifty work was inspired by Media Alias Display.
Suppose a custom block of content has a new draft revision with updated text. For any user visiting a page with that block, the default revision will (by default) be rendered. How do we render that latest revision for an authenticated user? With an old-school hook_alter().
We’re interested in user-generated content blocks as opposed to non-moderated, system-generated menu- or webform- blocks, so we implement hook_block_view_BLOCK_BASE_ID_alter() with BLOCK_BASE_ID = “custom_block”. Again, we check for revisions and permissions.
If we need to override the default block render with the latest revision, we add a pre_render callback to the block render array. The callback function loads the latest block revision, renders the block and overwrites the render array’s content key with the new markup.
Suppose we have a node type that does not have an individual detail view, it is only ever rendered as a listing in a view. When we create a revision for review, we do not want to send the stakeholder to “/node/N/latest”, we want them to review the updates on the view page.
We ensure all the appropriate views were built using Content revisions in the Views UI. The sets node_revision as the base table and builds in all the necessary table JOINs. We implement a custom filter plugin that adds a where clause to the query() method, depending on user’s permissions.
When configured to return revisions, Views UI only allows you to Show: Fields under Formatting. Fortunately, you do still have the option under Fields to render the entire entity using a view mode. Note that this requires a patch which only a PHP unit test away from the finish line. Finally, we add an unpublished_flag class to the row container field in hook_preprocess_views_view_fields() if the row’s entity is not the default revision.
The fun is multiplied when considering entities which themselves reference other entities. For instance, a block of content is placed on a page in a paragraph entity with a Block Field instance. That block_content entity itself references another page node or media entity. Data from that second node or media entity is what we ultimately render on the page as a tile, but we flag that tile (to authenticated users) as unpublished if either the custom block entity OR the referenced entity are unpublished.
Front End Rendering
When we render these entities with unpublished flags, we add a CSS class that signifies to our stakeholder the block or view listing is showing updated or unpublished information. This can be in the form of a colored border or background or overlay.
Another class can implement a floating overlay that offers links to directly update the moderation status of the entity, leveraging implementation of contrib module Content Moderation Link. We recommend enhancing the user experience by inserting modal forms into the process, giving the stakeholder the opportunity to add custom messaging in the Revision Log field, and offering a conditional confirmation page, reiterating that publishing a change to this shared entity will affect pages X, Y, and Z.
Bounteous created custom solutions for most of this work, but we note several interesting modules in the community that are following similar paths, such as Moderation Note, and Content Moderation Info Block.
There are so many content types with so many points of entry to create solutions. Because of this, the continued standardization of an entity-agnostic set of tools for revision handling is a welcome enhancement. When creating a robust workflow process for your site-builders, remember to treat stakeholder users as the distinct population they are. Address their specific needs and empower them to keep site content correct, timely and safe.