Search code examples
strapi

How to validate in Strapi (typescript) before creating one content that combined key is unique?


I am using "@strapi/strapi": "^4.12.5", and have to say that this is one amazing open-source CMS.

There are things missing, like the one I need help with and will explain below.

I have the following content-type named Block with the schema as below

{
  "kind": "collectionType",
  "collectionName": "blocks",
  "info": {
    "singularName": "block",
    "pluralName": "blocks",
    "displayName": "Block"
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {
    "i18n": {
      "localized": true
    }
  },
  "attributes": {
    "container": {
      "pluginOptions": {
        "i18n": {
          "localized": false
        }
      },
      "type": "string",
      "maxLength": 512,
      "required": true,
      "unique": false
    },
    "key": {
      "pluginOptions": {
        "i18n": {
          "localized": false
        }
      },
      "type": "string",
      "maxLength": 512,
      "required": true
    },
    "sections": {
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      },
      "type": "dynamiczone",
      "components": [
        "sections.rich-text"
      ],
      "required": true
    }
  }
}

I want to make sure that the combination of container-key-locale cannot be present 2 times, so that combination is unique.

Example of the desired functionality:

  1. user inserts for locale en container=home, key=footer, anyOtherDataDoesntMatter
  2. user tries to insert again for locale en, container=home, key=footer, whateverRestOfData. This operation should fail with proper validation error that "container-key-locale" is unique.

This is the default generated src/api/block/controllers/block.ts controller

/**
 * block controller
 */

import { factories } from '@strapi/strapi';

export default factories.createCoreController('api::block.block');

What is the best approach to add this validation logic and show a meaningful message to the admin UI ?

With the code and schema that I shared there is no validation in place and the content editor can add duplicate configs like the one I want to avoid.

PS: Strapi does not allow combinated keys or unique key per locale yet out of the box via the UI.


Solution

  • I found the solution I was looking for.

    I used lifecycles.ts to hook into the beforeCreate and beforeUpdate event of my content type. https://docs.strapi.io/dev-docs/backend-customization/models#lifecycle-hooks

    I did this by creating src/api/block/content-types/block/lifecycles.ts with the following code:

    // eslint-disable-next-line import/no-extraneous-dependencies
    import { YupValidationError } from '@strapi/utils/dist/errors';
    // eslint-disable-next-line import/no-extraneous-dependencies
    import { ValidationError } from 'yup';
    
    const resourceType = 'api::block.block';
    
    type Data = { locale: string; container: string; key: string };
    
    export default {
        async beforeCreate(event) {
            const { data } = event.params;
    
            const existing = await getExisting(data);
            if (existing) {
                throwDuplicateCombinedKeyValidationError();
            }
        },
    
        async beforeUpdate(event) {
            const id = event.params.where.id;
    
            const { data } = event.params;
            // Strapi has a bug that does not send the locale during update so we have to fetch the existing entry and see its locale
            // only the updated values are sent into data, we have to merge with existing actual ones
            const actual = await getById(id);
    
            // eslint-disable-next-line no-undef
            const existingWithCombinedKey = await getExisting({
                locale: actual.locale,
                container: data.container ?? actual.container,
                key: data.key ?? actual.key,
            });
    
            if (existingWithCombinedKey !== null && existingWithCombinedKey.id !== id) {
                throwDuplicateCombinedKeyValidationError();
            }
        },
    };
    
    async function getById(id: number): Promise<Data | null> {
        // @ts-ignore
        // eslint-disable-next-line no-undef
        const block = await strapi.service(resourceType).findOne(id);
    
        if (block === null) {
            return null;
        }
    
        return block;
    }
    
    function throwDuplicateCombinedKeyValidationError(): void {
        const errorMessage: ValidationError = {
            inner: [
                {
                    name: 'ValidationError', // Always set to ValidationError
                    path: 'container', // Name of field we want to show input validation on
                    message: 'duplicate', // Input validation message
                },
                {
                    name: 'ValidationError', // Always set to ValidationError
                    path: 'key', // Name of field we want to show input validation on
                    message: 'duplicate', // Input validation message
                },
            ],
        } as ValidationError;
    
        throw new YupValidationError(errorMessage, 'Duplicate combined key container-key-locale error');
    }
    
    async function getExisting(data: Data): Promise<{ id: number } | null> {
        // @ts-ignore
        // eslint-disable-next-line no-undef
        return await strapi.db
            .query(resourceType)
            .findOne({ where: { container: data.container, key: data.key, locale: data.locale } });
    }
    

    Throwing the YupValidationError error is supported by the frontend as well and you can achieve my 2nd goal of showing proper message to the content editor user:

    enter image description here

    I want to thank the Strapi team for the great product and also suggest that they extend the Typescript types for the events for easier development time.

    event has no type in Strapi atm, also the global strapi does not work.