For those struggling with this problem, I’ve created custom code for Strapi v4. Put this anywhere you want (lifecycle-helpers.ts for example)
import { Attribute } from '@strapi/types';
import { Event } from '@strapi/database/dist/lifecycles';
import type { Common } from '@strapi/types/dist/types/core';
import { ID } from '@strapi/database/dist/types';
import type { GetValues } from '@strapi/types/dist/modules/entity-service/result';
import { GetNonPopulatableKeys, GetPopulatableKeys } from '@strapi/types/dist/types/core/attributes';
export type LifecycleEvent<T extends Common.UID.Schema> = Event & {
result?: Attribute.GetValues<T>;
};
type CmsData = {
updatedBy: number;
publishedAt: Date | null;
updatedAt: Date | null;
connect?: any[];
disconnect?: any[];
};
const { ValidationError } = require('@strapi/utils').errors;
export const getRequiredContentTypeRelationNames = async <T extends Common.UID.Schema>(schemaName: T): Promise<string[]> => {
const attributes = strapi.getModel(schemaName)?.attributes;
if (!attributes) {
strapi.log.warn(`Schema "${schemaName}" not found, thus cannot check required relations.`);
return [];
}
return Object.keys(attributes).filter(key =>
attributes[key].type === 'relation' &&
attributes[key]?.required &&
!('private' in attributes[key])
);
}
/**
* @param entityId
* @param entityName e.g. 'api::marina.marina'
* @param populate allows nesting so ['otherMarinas.marinas'] instead of ['otherMarinas', 'otherMarinas.marinas']
* * - means all root fields are populated
* @see https://docs.strapi.io/dev-docs/api/entity-service/populate
*/
export const getEntity = async <T extends Common.UID.ContentType>(
entityId: ID,
entityName: T,
populate: string[] | '*' = '*',
): Promise<GetValues<T, GetPopulatableKeys<T> | GetNonPopulatableKeys<T>> | null> => {
return await strapi.entityService.findOne(
entityName as Common.UID.ContentType,
entityId,
{ populate: populate as any }, // Strapi types are incorrect here
) as unknown as GetValues<T, GetPopulatableKeys<T> | GetNonPopulatableKeys<T>> | null;
};
const isUnpublishingOrDraft = (cmsData: CmsData) => !cmsData.publishedAt;
/**
* @param event - CMS Event
* @param contentTypeName e.g. 'api::marina.marina'
* @param nestedComponentRelations if you want to check nested relations in given component e.g. ['crewSection.people']
*
* This method is meant to be used with beforeUpdate() hook, as beforeUpdate() fires upon publishing the entity.
*
* Check if all the required relations are chosen, there are two cases:
* 1. There is a no relation in DB and no relation chosen in CMS
* 2. There is a relation in DB and relation is being disconnected in CMS
*/
export const checkHasAllTheRequiredRelations = async <T extends Common.UID.ContentType>(
event: LifecycleEvent<T>,
contentTypeName: T,
nestedComponentRelations?: string[],
): Promise<void | never> => {
const {
result,
params: { where, data: cmsData }
} = event;
if (isUnpublishingOrDraft(cmsData)) {
return;
}
const requiredRelations = await getRequiredContentTypeRelationNames(contentTypeName);
const relationsToCheck = [
...requiredRelations,
...(nestedComponentRelations || []) // only if provided
];
if (relationsToCheck.length === 0) {
return;
}
const contentTypeId = where?.id || result?.id;
const entityData = await getEntity(contentTypeId, contentTypeName, relationsToCheck);
relationsToCheck.forEach((rel) => {
const relationName = rel.split('.');
const entityDataValue = relationName.reduce((acc, key) => acc?.[key], entityData);
const cmsDataValue: CmsData = relationName.reduce((acc, key) => acc?.[key], cmsData as CmsData);
const entityDataIsEmpty = !entityDataValue || (Array.isArray(entityDataValue) && entityDataValue.length === 0);
const connectingNoRelation = entityDataIsEmpty && !cmsDataValue?.connect.length;
const disconnectingExistingRelation = Array.isArray(entityDataValue) &&
!cmsDataValue?.connect.length &&
cmsDataValue?.disconnect.length > 0;
if (connectingNoRelation || disconnectingExistingRelation) {
throw new ValidationError(`Missing "${relationName}" relation. Please fill in the required relation field.`);
}
})
};
Then, in EVERY src/api/component-name/content-types/component-name/lifecycles.ts (create if not existing), put this lines
const CONTENT_TYPE = 'api::job-offer.job-offer';
module.exports = {
async beforeUpdate(event: Event) {
await checkHasAllTheRequiredRelations(event, CONTENT_TYPE);
},
};
Where CONTENT_TYPE is your actual content type from Strapi. In path src/api/component-name/content-types/component-name/lifecycles.ts
it’d be api::component-name.component-name
.
You may also get directory name and glue it into api::dirname.dirname
(using path
module or splitting __dirname
somehow). Like this
module.exports = {
async beforeUpdate(event: Event) {
const directoryName = path.basename(__dirname);
const contentType = `api::${directoryName}.${directoryName}` as Common.UID.ContentType;
await checkHasAllTheRequiredRelations(event, contentType);
},
};
Relations will be checked ONLY upon trying to Publish entity. You can Save, Unpublish as you want, but it won’t allow you to Publish without required relations. Just mark relations required: true
in your schemas, everything will work out-of-the-box.
PS > You can add your own nested relations as a param. Like this
const CONTENT_TYPE = 'api::about-us-page.about-us-page';
const ADDITIONAL_RELATIONS_TO_CHECK = ['crewSection.people'];
module.exports = {
async beforeUpdate(event: Event) {
await checkHasAllTheRequiredRelations(event, CONTENT_TYPE, ADDITIONAL_RELATIONS_TO_CHECK);
},
};
PS 2 > You’ll need to create lifecycles.ts for EVERY component type, unfortunately.