Required Relation Field

hi @hunter

can you just help me to how to implement this service in my case beacuse i’m facing same issue for required true

i have no custom controller & no custom service till now
then how would i integrate this service

My files Code ::
service/project.js

'use strict';

/**
 * project service
 */

const { createCoreService } = require('@strapi/strapi').factories;

module.exports = createCoreService('api::project.project');

controller/project.js

'use strict';

/**
 * project controller
 */

const { createCoreController } = require('@strapi/strapi').factories;

module.exports = createCoreController('api::project.project');

and the last one schema.json

{
  "kind": "collectionType",
  "collectionName": "projects",
  "info": {
    "singularName": "project",
    "pluralName": "projects",
    "displayName": "Project",
    "description": ""
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {},
  "attributes": {
    "title": {
      "type": "string",
      "required": true
    },
    "description": {
      "type": "richtext",
      "required": true
    },
    "projectMediumCategory": {
      "type": "relation",
      "relation": "oneToOne",
      "target": "api::medium-category.medium-category",
      "required": true
    },
    "projectSubjectMatterCategories": {
      "type": "relation",
      "relation": "oneToMany",
      "target": "api::subject-matter-category.subject-matter-category",
      "required": true
    }
  }
}

This issue only occurred for oneToMany relation keep in Mind Guy’s

Please help me ! @hunter

1 Like

Have the same question
up to 4.4.5 no effect

Hi @hunter,

checking the publishedAt property works perfectly if you’re publishing, but what about un-publish? In that case, publishedAt is also null.

Is there any other way to check how beforeUpdate was invoked?

Best regards,
Tom

For me, the undocumented feature of simply setting required: true worked up until 4.5.0 where https://github.com/strapi/strapi/pull/14401/ was released.

With that PR the content manager started submitting more complex object as the value for relations fields even if the value is empty. Unfortunately, this object passes through the required validator as it merely checks the value isn’t null or undefined.

BTW, if you implemented this check manually in your controllers and you really need to enforce this invariant you should consider if your check handles this new syntax. It goes roughly like this:

relation_attribute: { 
  connect: [<ids to attach>],
  disconnect: [<ids to detach>)
}

Note that this syntax is “procedural” - it doesn’t state what the resulting data should be, but rather describes how to achieve the desired state by a set of updates. This makes a robust required check quite hard to pull-off as you’d have to first pull the current state of the entity from the DB, run the proposed updates, and only then check if the result is empty or not. If you think that embedding a procedural syntax inside a REST API is unfortunate well, you’re not alone. :man_shrugging:

2 Likes

You can follow the progress in the feature request: Specify relation column as required | Content Editing XP | Strapi I can’t give you an estimate, but we are currently exploring adding this option to relational fields and will notify you through the feature request.

2 Likes

thanks this worked as charm, just going to the schema and add anoer required manually, thanska a lot x)

Posting a solution for strapi versions above 4.5.0

To check if relation field is filled every time you change it you have to:
“check strapi data” - I check strapi data by doing this:
It goes through array of fields to “check” and it returns the field value if field was changed or false

function checkField(data, fieldName) {
  const field = data[fieldName]
  return { [fieldName]: field.connect.length > 0 && { id: field.connect[0].id}  }
}

function checkContentData(data, fieldsArray) {
  const checkedData = fieldsArray.reduce((result, field) => {
    result[field] = checkField(data, field)[field];
    return result;
  }, {});
  return {
    ...data,
    ...checkedData
  }
}

and use it in lifecycles while passing an array of relation fields I want to check
so:

const checkedData = checkContentData(data, ['field_name', 'field_name_2'])

This gets you a connect value, you only use disconnect in if statements below

in if statement you must use the data from
const { data } = event.params;
but in your validation logic you pass in checkedData, because you only validate it if it was disconnected

This is used in beforeUpdate lifecycle:

if(data.field_name.disconnect.length === 1 && data.field_name.connect.length === 0) {
  if(!checkedData.field_name) {
    throw new ForbiddenError('relation field is empty)
  }
}

This is used in beforeCreate:
because if it wasn’t touched then you know it’s empty

if(data.field_name.disconnect.length === 0 && data.field_name.connect.length === 0) {
  if(!checkedData.field_name) {
    throw new ForbiddenError('relation field is empty)
  }
}

This is such an important feature and it’s not implemented. Incredible.

To be specific, in Strapi 4.16.2 (the latest version at the time of writing this), adding the required: true to the relation attribute still does not prevent the creation of the entry in the interface, although the relation field has a red asterisk close to it, like normal required attributes have. This is strange because Strapi automatically updates the types once we do this. So, clearly, there’s a bug in Strapi, which has been around for so many versions and years, and the developers don’t seem to care. Moreover, in the interface, we cannot set this relation attribute to be required, like for normal attributes.

1 Like

Yes. I am having the same issue.

Did you check Strapi 5?

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.

EDIT!!!

Switch

const isUnpublishingOrDraft = (cmsData: CmsData) => !cmsData.publishedAt;

to

const isPublishingOrUpdatingPublished = (entityData: any, cmsData: CmsData) => {
  return entityData.publishedAt === null && cmsData.publishedAt || // Publishing
    entityData.publishedAt && cmsData.publishedAt === undefined; // Updating published
}

Remove

if (isUnpublishingOrDraft(cmsData)) {
    return;
  }

And put it like this

  const contentTypeId = where?.id || result?.id;
  const entityData = await getEntity(contentTypeId, contentTypeName, relationsToCheck);

// Do not check if entity is being updated in a draft mode
  if (!isPublishingOrUpdatingPublished(entityData, cmsData)) {
    return;
  }

Also, this is valid part of code

    const disconnectingAllExistingRelations = Array.isArray(entityDataValue) &&
      !cmsDataValue?.connect.length &&
      cmsDataValue?.disconnect.length === entityDataValue.length;

    if (connectingNoRelation || disconnectingAllExistingRelations) {
      throw new ValidationError(`Missing "${relationName}" relation. Please fill in the required relation field.`);
    }