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.