Abby Milberg

Enforcing required fields on drafts in Strapi

Published on

The Challenge

On Strapi content types with "Draft&Publish" enabled, Strapi allows users to save an unpublished draft even if one or more required fields are left empty. The maintainers of Strapi have made it clear that this is functioning as designed. But one developer's feature is another's bug, and a quick search revels that I'm far from the only person who's Mad About This On The Internet. So let's fix it!

The Solution

The following code has been tested on Strapi v4.15.5. All changes are made to src/index.js.

"use strict";

const utils = require("@strapi/utils");
const { ValidationError } = utils.errors;

/**
 * A function that accepts a Strapi lifecycle event and returns an array
 * of required fields that don't have a value set.
 */
const getEmptyRequiredFields = (event, customComponents) => {
  if (!event.model || !event.model.modelType) {
    return [];
  }

  switch (event.model.modelType) {
    case 'component': 
      // Stop it from running on core components and config.
      if (!customComponents.includes(event.model.collectionName)) {
        return []
      }
      break;
    case 'contentType': 
      // Don't run on published content; Strapi handles that already.
      // Only run on content types we've created, which start with 'api::',
      // otherwise it tries to run on core stuff like user profiles.
      if (
        event.params.data.publishedAt || 
        event.model.uid.substring(0,5) !== 'api::'
      ) {
        return []
      }
      break;
    default:
      // Don't run on anything that isn't one of our components or content types.
      return []
      break;
  }

  const modelSchema = strapi.getModel(event.model.uid);
  if (!modelSchema || !modelSchema.attributes) {
    return [];
  }

  // It made it past all our weird edge cases...
  // Run our actual checks on each field
  return Object.entries(modelSchema.attributes)
    .filter(([, props]) => {
      return props.required;
    })
    .map(([fieldName]) => {
      return fieldName;
    })
    .reduce((acc, fieldName) => {
      const fieldValue = event.params.data[fieldName];

      // If it returns an actual true or false it's getting it from a boolean
      // field in Strapi; empty fields return null
      if (typeof fieldValue === 'boolean') {
        return acc;
      }

      // Check for empty strings and arrays
      if (
        (typeof fieldValue === 'string' || Array.isArray(fieldValue)) &&
        !fieldValue.length
      ) {
        return [...acc, fieldName]
      }

      // No value
      if (!fieldValue) {
        return [...acc, fieldName]
      }

      // It has a value
      return acc;
    }, []);
}

module.exports = {
  bootstrap({ strapi }) {
    // List slugs of custom components that we've created via site-building
    const customComponents = Object.values(strapi.components).map((vals) => {
      return vals.collectionName;
    });

    strapi.db.lifecycles.subscribe((event) => {
      switch (event.action) {
        case 'beforeCreate':
        case 'beforeUpdate':
          const emptyRequiredFields = getEmptyRequiredFields(event, customComponents);

          if (emptyRequiredFields.length > 0) {
            throw new ValidationError(`
              Unable to save. Please fill in the following required fields and try again: 
              ${emptyRequiredFields.join(', ')}
            `);
          }
          break;
        default:
          break;
      }
    });
  },
};

I've commented the code fairly thoroughly, rather than trying to break this down line by line, but let's run through the general idea.

Strapi offers lifecycle hooks that allow us to subscribe to events that trigger a database query. We're listening for the beforeCreate and beforeUpdate events, so that we can intervene before Strapi actually saves a piece of content and throw an error if any required fields are missing.

My biggest challenge when working on this was that these lifecycle events fire on any database query, not just what we tend to think of as content changes. (Think user roles, webhook configuration, media content... the list goes on and on.) As such, a huge portion of my getEmptyRequiredFields function is dedicated solely to making sure that we only return values when dealing with one of our custom content types or components.

Starting with // It made it past all our weird edge cases..., we start actually checking fields. We grab a list of all of the component or content type's fields (modelSchema.attributes), filter it to include only those that are required, then check to see if the required fields have data. Because different fields store data as different variable types, we have to run through a few different checks (for instance, false is a valid value for a boolean field, etc).

Finally, getEmptyRequiredFields returns an array of the fieldName of any fields that are empty and required. Jumping back down to the lifecycle event, we throw a ValidationError with the name(s) of those field(s). In the content admin interface, this will prevent the user from saving and show them the error so that they can fill out the missing fields.

Sharing is caring!