Strapi v3: Making the Impossible Possible

Strapi v3: Making the Impossible Possible

22 Feb 2022

22 Feb 2022

Katarzyna Stokłosa

Katarzyna Stokłosa

Strapi v3: Making the Impossible Possible - cover graphic
Strapi v3: Making the Impossible Possible - cover graphic
Strapi v3: Making the Impossible Possible - cover graphic

Creating customisable fields in Strapi v3.

Strapi is an open-source headless CMS that enables developers to make and manage flexible API structures with ease.  It comes with a great UI, and some might even say that having everything set up out of the box is one of the main upsides of Strapi.

Sometimes, however, you find yourself in a situation where you feel that everything you‘ve been given is not good enough and you wish there was some way to customise your CMS just a little more. You’ve probably searched high and low trying to find a perfect solution with no success. The good news is that I’m here to help you!

I’ll be using Strapi 3.6.6 for this tutorial — please bear in mind that the presented solution may differ in older releases. Regardless of the version, the workflow is very similar — after following this tutorial, you will be able to came up with a solution that fits your needs.


The problem

Let’s assume that I’m working on some CMS that (for some odd reasons) holds a list of fruits. My model contains two main fields — name and description.


Collection Types / Fruits


I want to decide which fruits should be displayed on my website. That’s why I’ve created Fruit_offer— Component with one-to-many relation to Fruits.


Components / Fruit_offer


The next step is the creation of a Single Type — Recipe. It will contain our Fruit_offer as a single component. But after going to the Recipe section… I’m not entirely satisfied with the result:


Single Type / Recipe


I just wish that I could pick all of the fruits at once. And having them displayed as a list with checkboxes? Oh, it sounds like a dream!


Why not make dreams come true?

It’s time to finally make our hands dirty. This requires some copy & paste actions from node_modules installed in our Strapi project.

But don’t give up now! I promise you, it’s going to be worth it.

Open your  terminal and navigate to your project directory. We have to create a copy of strapi-plugin-content’s FieldComponent and EditView directories into our project. For the first one simply type:

cp -r node_modules/strapi-plugin-content-manager/admin/src/components/FieldComponent/. extensions/content-manager/admin/src/components/FieldComponent

And for the EditView:

cp-r node_modules/strapi-plugin-content-manager/admin/src/containers/EditView/. extensions/content-manager/admin/src/containers/EditView


The current state of content-manager extension in our application

Before we start, we need to add one thing to our Recipe model. Find the recipe.settings.json file and create an additional property that would help us distinguish our component from the others. For me, it will be a boolean custom_ui (line 18).

<div className="row" key={fieldsBlockIndex}>
  {fieldsBlock.map(
    ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => {
      const isComponent = fieldSchema.type === 'component';
      const hasCustomUI = fieldSchema['custom_ui'] ?? false;

      if (isComponent && !hasCustomUI) {
        const { component, max, min, repeatable = false } = fieldSchema;
        const componentUid = fieldSchema.component;

        return (
          <FieldComponent
            key={componentUid}
            componentUid={component}
            labelIcon={labelIcon}
            isRepeatable={repeatable}
            label={metadatas.label}
            max={max}
            min={min}
            name={name}
          />
        );
      }

      if(isComponent && hasCustomUI) {
        return <h1>THERE'S A PLACE FOR OUR NEW COMPONENT! </h1>
      }

      return (
        <div className={`col-${size}`} key={name}>
          <Inputs
            autoFocus={
              blockIndex === 0 &&
              fieldsBlockIndex === 0 &&
              fieldIndex === 0
            }
            fieldSchema={fieldSchema}
            keys={name}
            labelIcon={labelIcon}
            metadatas={metadatas}
          />
        </div>
      );
    }
  )}
</div>

Now it’s time to go back to EditView container. Find there index.js file, go to the line with fieldsBlock mapping, and create a placeholder for our new component (this way you can be sure that everything was set up correctly):

<div className="row" key={fieldsBlockIndex}>
  {fieldsBlock.map(
    ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => {
      const isComponent = fieldSchema.type === 'component';
      const hasCustomUI = fieldSchema['custom_ui'] ?? false;

      if (isComponent && !hasCustomUI) {
        const { component, max, min, repeatable = false } = fieldSchema;
        const componentUid = fieldSchema.component;

        return (
          <FieldComponent
            key={componentUid}
            componentUid={component}
            labelIcon={labelIcon}
            isRepeatable={repeatable}
            label={metadatas.label}
            max={max}
            min={min}
            name={name}
          />
        );
      }

      if(isComponent && hasCustomUI) {
        return <h1>THERE'S A PLACE FOR OUR NEW COMPONENT! </h1>
      }

      return (
        <div className={`col-${size}`} key={name}>
          <Inputs
            autoFocus={
              blockIndex === 0 &&
              fieldsBlockIndex === 0 &&
              fieldIndex === 0
            }
            fieldSchema={fieldSchema}
            keys={name}
            labelIcon={labelIcon}
            metadatas={metadatas}
          />
        </div>
      );
    }
  )}
</div>

…and the result:

The current view in the Recipe section


Are you still seeing the old version? Make sure that your app is running in watch mode. If that mode is not available for you — delete .cache&build folders and re-run your application every time you want to see the change you’ve made.


Preparation

At this point, it’s good to distinguish the most important functionalities related to our component:

  1. It should display a list of all available fruits (in the form of the fruit’s name with a checkbox)

  2. We want to be able to check/uncheck all fields at once

  3. Selecting/unselecting given fruit should not affect the Recipe model’s data (unless we press save)

That’s it. Now we’re ready to start working on our component!  🎉

Creating the component

For this part, I’ll use components from buffetjs (already installed in strapi) to speed up the process a little bit.

If you want to get the list of all of the fruits that are available in your database, you need to make a request there. Create utils directory with fruits.js file:

import { request } from "strapi-helper-plugin";

export const getFruits = async () => await request("/fruits");

Inside the components section create the FruitsComponent folder. It will contain FruitsList directory, FruitsList.parts.js with a single fruit component & index.js where you can find the logic for displaying all of the fruits.

Let’s start with FruitsList.parts.js:

import React from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from '@buffetjs/core';

export const Fruit = ({ fruit, label, checked }) => {
  const handleCheckboxChange = () => {}

  return (
    <Checkbox 
      name={fruit.name} 
      message={label} 
      value={checked} 
      onChange={handleCheckboxChange} 
    />
  );
};

Fruit.propTypes = {
  fruit: PropTypes.object,
  label: PropTypes.string,
  checked: PropTypes.bool,
};

Fruit.defaultProps = {
  fruit: {},
  label: '',
  checked:

Then, inside index.js in FruitsList directory:

import React from 'react';
import PropTypes from 'prop-types';
import { Fruit } from './FruitsList.parts.js';

export const FruitsList = ({ fruits, checkedFruits }) => {
  const hasFruits = fruits.length !== 0;

  if (!hasFruits) {
    return <p>No fruits available</p>;
  }

  return (
    <ul>
      {fruits.map((fruit, index) => {
        const key = `_fruit_${fruit.name}`;
        const label = `${index + 1}. ${fruit.name}`;
        const fieldIndex = checkedFruits.findIndex((item) => item?.id === fruit.id);
        const checked = fieldIndex !== -1;

        return (
          <Fruit 
            fruit={fruit} 
            key={key} 
            label={label} 
            checked={checked} 
            fieldIndex={fieldIndex} 
          />
        );
      })}
    </ul>
  );
};

FruitsList.propTypes = {
  fruits: PropTypes.array,
  checkedFruits: PropTypes.array,
};

FruitsList.defaultProps = {
  fruits: [],
  checkedFruits: [],
};

When everything is ready, we can now create FruitsComponent (FruitsComponent/index.js). As you can see, our array of checkedFruits comes from the componentValue passed on by the connect function (lines 9, 25 & 31):

import React, { memo, useState, useEffect } from 'react';
import isEqual from 'react-fast-compare';
import { Padded, Text, Checkbox } from '@buffetjs/core';
import { getFruits } from '../../utils/fruits';
import connect from '../FieldComponent/utils/connect';
import select from '../FieldComponent/utils/select';
import { FruitsList } from './FruitsList';

const FruitComponent = ({ componentValue }) => {
  const [fruits, setFruits] = useState([]);
  const hasComponentValue = componentValue !== null;

  useEffect(() => {
    getFruits().then(setFruits);
  }, []);

  if (!hasComponentValue && fruits.length === 0) {
    return null;
  }

  return (
    <Padded top left right bottomsize="smd">
      <Text fontWeight="bold">Fruits List</Text>
      <Checkbox message="Select All" name="selectAll" />
      <FruitsList fruits={fruits} checkedFruits={componentValue?.fruits} />
    </Padded>
  );
};

const Memoized = memo(FruitComponent, isEqual);
export default connect(Memoized, select);

Now it’s finally time to update EditView container:

import { useContentManagerEditViewDataManager } from 'strapi-helper-plugin';

export const useFruitField = ({ fieldIndex }) => {
  const { addRelation, onRemoveRelation } = useContentManagerEditViewDataManager();

  const FIELD_NAME = 'fruits_list.fruits';

  const add = (fruit) => {
    const { id, name } = fruit;
    addRelation({
      target: {
        name: FIELD_NAME,
        value: [{ label: name, value: { id, name } }],
      },
    });
  };

  const remove = (fieldIndex) => onRemoveRelation(`${FIELD_NAME}.${fieldIndex}`);

  return { add, remove };
};

And a quick check of what our Recipe looks like in the application:

Recipe section


Right now, clicking on checkboxes does not change anything. But don’t worry — we’re halfway through!


Creating hooks & subscribing to events

The main difficulty we need to face right now is changing the state in a way that would trigger the save button (placed in the top right corner of our CMS page). In the FruitsComponent/FruitsList directory create FruitsList.hooks.js file:

import { useContentManagerEditViewDataManager } from 'strapi-helper-plugin';

export const useFruitField = ({ fieldIndex }) => {
  const { addRelation, onRemoveRelation } = useContentManagerEditViewDataManager();

  const FIELD_NAME = 'fruits_list.fruits';

  const add = (fruit) => {
    const { id, name } = fruit;
    addRelation({
      target: {
        name: FIELD_NAME,
        value: [{ label: name, value: { id, name } }],
      },
    });
  };

  const remove = (fieldIndex) => onRemoveRelation(`${FIELD_NAME}.${fieldIndex}`);

  return { add, remove };
};

Imported useContentManagerEditViewDataManager is a built-in hook that contains all available methods from EditViewDataManagerProvider. Since we are not dealing with more complicated structures (such as nested components and repeatable fields), we can use the basic ones — addRelation and onRemoveRelation. Do you remember when we created our single type  —  Recipe? Take a look at the screenshot below— this is where our FIELD_NAME comes from.

Now, when everything is ready, we can update FruitsList.parts.js:

import { useFruitField } from './FruitsList.hook';

export const Fruit = ({ fruit, label, checked, fieldIndex }) => {
  const { add, remove } = useFruitField();

  const handleCheckboxChange = () => (checked ? remove(fieldIndex) : add(fruit));

  return (
    <Checkbox 
      name={fruit.name} 
      message={label} 
      value={checked} 
      onChange={handleCheckboxChange} />
  );
};

Fruit.propTypes = {
  fruit: PropTypes.object,
  label: PropTypes.string,
  fieldIndex: PropTypes.number,
  checked: PropTypes.bool,
};

And voilà!

Here you can find the complete code of the content-manager with topics that were not covered in this article — logic for selecting all fruits at once and displaying fruit’s description on hover.


Final thoughts

I know that the above steps were not the easiest, but hooray, you made it! 🙌   And while we are still waiting for this feature to be released in one of the upcoming versions of Strapi, it’s good to have some alternatives for the older ones.

I highly encourage you to play with customising your fields even more— it might be a good starting point for more complex solutions and components (such as maps, colour-pickers or even custom plugins).

And don’t forget to share your final result with us on Twitter. We look forward to seeing your custom fields in action!


Last but not least, special thanks to my colleague, Kacper Łukawski, for mentoring and teaching me how to “make the impossible possible”.

Creating customisable fields in Strapi v3.

Strapi is an open-source headless CMS that enables developers to make and manage flexible API structures with ease.  It comes with a great UI, and some might even say that having everything set up out of the box is one of the main upsides of Strapi.

Sometimes, however, you find yourself in a situation where you feel that everything you‘ve been given is not good enough and you wish there was some way to customise your CMS just a little more. You’ve probably searched high and low trying to find a perfect solution with no success. The good news is that I’m here to help you!

I’ll be using Strapi 3.6.6 for this tutorial — please bear in mind that the presented solution may differ in older releases. Regardless of the version, the workflow is very similar — after following this tutorial, you will be able to came up with a solution that fits your needs.


The problem

Let’s assume that I’m working on some CMS that (for some odd reasons) holds a list of fruits. My model contains two main fields — name and description.


Collection Types / Fruits


I want to decide which fruits should be displayed on my website. That’s why I’ve created Fruit_offer— Component with one-to-many relation to Fruits.


Components / Fruit_offer


The next step is the creation of a Single Type — Recipe. It will contain our Fruit_offer as a single component. But after going to the Recipe section… I’m not entirely satisfied with the result:


Single Type / Recipe


I just wish that I could pick all of the fruits at once. And having them displayed as a list with checkboxes? Oh, it sounds like a dream!


Why not make dreams come true?

It’s time to finally make our hands dirty. This requires some copy & paste actions from node_modules installed in our Strapi project.

But don’t give up now! I promise you, it’s going to be worth it.

Open your  terminal and navigate to your project directory. We have to create a copy of strapi-plugin-content’s FieldComponent and EditView directories into our project. For the first one simply type:

cp -r node_modules/strapi-plugin-content-manager/admin/src/components/FieldComponent/. extensions/content-manager/admin/src/components/FieldComponent

And for the EditView:

cp-r node_modules/strapi-plugin-content-manager/admin/src/containers/EditView/. extensions/content-manager/admin/src/containers/EditView


The current state of content-manager extension in our application

Before we start, we need to add one thing to our Recipe model. Find the recipe.settings.json file and create an additional property that would help us distinguish our component from the others. For me, it will be a boolean custom_ui (line 18).

<div className="row" key={fieldsBlockIndex}>
  {fieldsBlock.map(
    ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => {
      const isComponent = fieldSchema.type === 'component';
      const hasCustomUI = fieldSchema['custom_ui'] ?? false;

      if (isComponent && !hasCustomUI) {
        const { component, max, min, repeatable = false } = fieldSchema;
        const componentUid = fieldSchema.component;

        return (
          <FieldComponent
            key={componentUid}
            componentUid={component}
            labelIcon={labelIcon}
            isRepeatable={repeatable}
            label={metadatas.label}
            max={max}
            min={min}
            name={name}
          />
        );
      }

      if(isComponent && hasCustomUI) {
        return <h1>THERE'S A PLACE FOR OUR NEW COMPONENT! </h1>
      }

      return (
        <div className={`col-${size}`} key={name}>
          <Inputs
            autoFocus={
              blockIndex === 0 &&
              fieldsBlockIndex === 0 &&
              fieldIndex === 0
            }
            fieldSchema={fieldSchema}
            keys={name}
            labelIcon={labelIcon}
            metadatas={metadatas}
          />
        </div>
      );
    }
  )}
</div>

Now it’s time to go back to EditView container. Find there index.js file, go to the line with fieldsBlock mapping, and create a placeholder for our new component (this way you can be sure that everything was set up correctly):

<div className="row" key={fieldsBlockIndex}>
  {fieldsBlock.map(
    ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => {
      const isComponent = fieldSchema.type === 'component';
      const hasCustomUI = fieldSchema['custom_ui'] ?? false;

      if (isComponent && !hasCustomUI) {
        const { component, max, min, repeatable = false } = fieldSchema;
        const componentUid = fieldSchema.component;

        return (
          <FieldComponent
            key={componentUid}
            componentUid={component}
            labelIcon={labelIcon}
            isRepeatable={repeatable}
            label={metadatas.label}
            max={max}
            min={min}
            name={name}
          />
        );
      }

      if(isComponent && hasCustomUI) {
        return <h1>THERE'S A PLACE FOR OUR NEW COMPONENT! </h1>
      }

      return (
        <div className={`col-${size}`} key={name}>
          <Inputs
            autoFocus={
              blockIndex === 0 &&
              fieldsBlockIndex === 0 &&
              fieldIndex === 0
            }
            fieldSchema={fieldSchema}
            keys={name}
            labelIcon={labelIcon}
            metadatas={metadatas}
          />
        </div>
      );
    }
  )}
</div>

…and the result:

The current view in the Recipe section


Are you still seeing the old version? Make sure that your app is running in watch mode. If that mode is not available for you — delete .cache&build folders and re-run your application every time you want to see the change you’ve made.


Preparation

At this point, it’s good to distinguish the most important functionalities related to our component:

  1. It should display a list of all available fruits (in the form of the fruit’s name with a checkbox)

  2. We want to be able to check/uncheck all fields at once

  3. Selecting/unselecting given fruit should not affect the Recipe model’s data (unless we press save)

That’s it. Now we’re ready to start working on our component!  🎉

Creating the component

For this part, I’ll use components from buffetjs (already installed in strapi) to speed up the process a little bit.

If you want to get the list of all of the fruits that are available in your database, you need to make a request there. Create utils directory with fruits.js file:

import { request } from "strapi-helper-plugin";

export const getFruits = async () => await request("/fruits");

Inside the components section create the FruitsComponent folder. It will contain FruitsList directory, FruitsList.parts.js with a single fruit component & index.js where you can find the logic for displaying all of the fruits.

Let’s start with FruitsList.parts.js:

import React from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from '@buffetjs/core';

export const Fruit = ({ fruit, label, checked }) => {
  const handleCheckboxChange = () => {}

  return (
    <Checkbox 
      name={fruit.name} 
      message={label} 
      value={checked} 
      onChange={handleCheckboxChange} 
    />
  );
};

Fruit.propTypes = {
  fruit: PropTypes.object,
  label: PropTypes.string,
  checked: PropTypes.bool,
};

Fruit.defaultProps = {
  fruit: {},
  label: '',
  checked:

Then, inside index.js in FruitsList directory:

import React from 'react';
import PropTypes from 'prop-types';
import { Fruit } from './FruitsList.parts.js';

export const FruitsList = ({ fruits, checkedFruits }) => {
  const hasFruits = fruits.length !== 0;

  if (!hasFruits) {
    return <p>No fruits available</p>;
  }

  return (
    <ul>
      {fruits.map((fruit, index) => {
        const key = `_fruit_${fruit.name}`;
        const label = `${index + 1}. ${fruit.name}`;
        const fieldIndex = checkedFruits.findIndex((item) => item?.id === fruit.id);
        const checked = fieldIndex !== -1;

        return (
          <Fruit 
            fruit={fruit} 
            key={key} 
            label={label} 
            checked={checked} 
            fieldIndex={fieldIndex} 
          />
        );
      })}
    </ul>
  );
};

FruitsList.propTypes = {
  fruits: PropTypes.array,
  checkedFruits: PropTypes.array,
};

FruitsList.defaultProps = {
  fruits: [],
  checkedFruits: [],
};

When everything is ready, we can now create FruitsComponent (FruitsComponent/index.js). As you can see, our array of checkedFruits comes from the componentValue passed on by the connect function (lines 9, 25 & 31):

import React, { memo, useState, useEffect } from 'react';
import isEqual from 'react-fast-compare';
import { Padded, Text, Checkbox } from '@buffetjs/core';
import { getFruits } from '../../utils/fruits';
import connect from '../FieldComponent/utils/connect';
import select from '../FieldComponent/utils/select';
import { FruitsList } from './FruitsList';

const FruitComponent = ({ componentValue }) => {
  const [fruits, setFruits] = useState([]);
  const hasComponentValue = componentValue !== null;

  useEffect(() => {
    getFruits().then(setFruits);
  }, []);

  if (!hasComponentValue && fruits.length === 0) {
    return null;
  }

  return (
    <Padded top left right bottomsize="smd">
      <Text fontWeight="bold">Fruits List</Text>
      <Checkbox message="Select All" name="selectAll" />
      <FruitsList fruits={fruits} checkedFruits={componentValue?.fruits} />
    </Padded>
  );
};

const Memoized = memo(FruitComponent, isEqual);
export default connect(Memoized, select);

Now it’s finally time to update EditView container:

import { useContentManagerEditViewDataManager } from 'strapi-helper-plugin';

export const useFruitField = ({ fieldIndex }) => {
  const { addRelation, onRemoveRelation } = useContentManagerEditViewDataManager();

  const FIELD_NAME = 'fruits_list.fruits';

  const add = (fruit) => {
    const { id, name } = fruit;
    addRelation({
      target: {
        name: FIELD_NAME,
        value: [{ label: name, value: { id, name } }],
      },
    });
  };

  const remove = (fieldIndex) => onRemoveRelation(`${FIELD_NAME}.${fieldIndex}`);

  return { add, remove };
};

And a quick check of what our Recipe looks like in the application:

Recipe section


Right now, clicking on checkboxes does not change anything. But don’t worry — we’re halfway through!


Creating hooks & subscribing to events

The main difficulty we need to face right now is changing the state in a way that would trigger the save button (placed in the top right corner of our CMS page). In the FruitsComponent/FruitsList directory create FruitsList.hooks.js file:

import { useContentManagerEditViewDataManager } from 'strapi-helper-plugin';

export const useFruitField = ({ fieldIndex }) => {
  const { addRelation, onRemoveRelation } = useContentManagerEditViewDataManager();

  const FIELD_NAME = 'fruits_list.fruits';

  const add = (fruit) => {
    const { id, name } = fruit;
    addRelation({
      target: {
        name: FIELD_NAME,
        value: [{ label: name, value: { id, name } }],
      },
    });
  };

  const remove = (fieldIndex) => onRemoveRelation(`${FIELD_NAME}.${fieldIndex}`);

  return { add, remove };
};

Imported useContentManagerEditViewDataManager is a built-in hook that contains all available methods from EditViewDataManagerProvider. Since we are not dealing with more complicated structures (such as nested components and repeatable fields), we can use the basic ones — addRelation and onRemoveRelation. Do you remember when we created our single type  —  Recipe? Take a look at the screenshot below— this is where our FIELD_NAME comes from.

Now, when everything is ready, we can update FruitsList.parts.js:

import { useFruitField } from './FruitsList.hook';

export const Fruit = ({ fruit, label, checked, fieldIndex }) => {
  const { add, remove } = useFruitField();

  const handleCheckboxChange = () => (checked ? remove(fieldIndex) : add(fruit));

  return (
    <Checkbox 
      name={fruit.name} 
      message={label} 
      value={checked} 
      onChange={handleCheckboxChange} />
  );
};

Fruit.propTypes = {
  fruit: PropTypes.object,
  label: PropTypes.string,
  fieldIndex: PropTypes.number,
  checked: PropTypes.bool,
};

And voilà!

Here you can find the complete code of the content-manager with topics that were not covered in this article — logic for selecting all fruits at once and displaying fruit’s description on hover.


Final thoughts

I know that the above steps were not the easiest, but hooray, you made it! 🙌   And while we are still waiting for this feature to be released in one of the upcoming versions of Strapi, it’s good to have some alternatives for the older ones.

I highly encourage you to play with customising your fields even more— it might be a good starting point for more complex solutions and components (such as maps, colour-pickers or even custom plugins).

And don’t forget to share your final result with us on Twitter. We look forward to seeing your custom fields in action!


Last but not least, special thanks to my colleague, Kacper Łukawski, for mentoring and teaching me how to “make the impossible possible”.

Creating customisable fields in Strapi v3.

Strapi is an open-source headless CMS that enables developers to make and manage flexible API structures with ease.  It comes with a great UI, and some might even say that having everything set up out of the box is one of the main upsides of Strapi.

Sometimes, however, you find yourself in a situation where you feel that everything you‘ve been given is not good enough and you wish there was some way to customise your CMS just a little more. You’ve probably searched high and low trying to find a perfect solution with no success. The good news is that I’m here to help you!

I’ll be using Strapi 3.6.6 for this tutorial — please bear in mind that the presented solution may differ in older releases. Regardless of the version, the workflow is very similar — after following this tutorial, you will be able to came up with a solution that fits your needs.


The problem

Let’s assume that I’m working on some CMS that (for some odd reasons) holds a list of fruits. My model contains two main fields — name and description.


Collection Types / Fruits


I want to decide which fruits should be displayed on my website. That’s why I’ve created Fruit_offer— Component with one-to-many relation to Fruits.


Components / Fruit_offer


The next step is the creation of a Single Type — Recipe. It will contain our Fruit_offer as a single component. But after going to the Recipe section… I’m not entirely satisfied with the result:


Single Type / Recipe


I just wish that I could pick all of the fruits at once. And having them displayed as a list with checkboxes? Oh, it sounds like a dream!


Why not make dreams come true?

It’s time to finally make our hands dirty. This requires some copy & paste actions from node_modules installed in our Strapi project.

But don’t give up now! I promise you, it’s going to be worth it.

Open your  terminal and navigate to your project directory. We have to create a copy of strapi-plugin-content’s FieldComponent and EditView directories into our project. For the first one simply type:

cp -r node_modules/strapi-plugin-content-manager/admin/src/components/FieldComponent/. extensions/content-manager/admin/src/components/FieldComponent

And for the EditView:

cp-r node_modules/strapi-plugin-content-manager/admin/src/containers/EditView/. extensions/content-manager/admin/src/containers/EditView


The current state of content-manager extension in our application

Before we start, we need to add one thing to our Recipe model. Find the recipe.settings.json file and create an additional property that would help us distinguish our component from the others. For me, it will be a boolean custom_ui (line 18).

<div className="row" key={fieldsBlockIndex}>
  {fieldsBlock.map(
    ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => {
      const isComponent = fieldSchema.type === 'component';
      const hasCustomUI = fieldSchema['custom_ui'] ?? false;

      if (isComponent && !hasCustomUI) {
        const { component, max, min, repeatable = false } = fieldSchema;
        const componentUid = fieldSchema.component;

        return (
          <FieldComponent
            key={componentUid}
            componentUid={component}
            labelIcon={labelIcon}
            isRepeatable={repeatable}
            label={metadatas.label}
            max={max}
            min={min}
            name={name}
          />
        );
      }

      if(isComponent && hasCustomUI) {
        return <h1>THERE'S A PLACE FOR OUR NEW COMPONENT! </h1>
      }

      return (
        <div className={`col-${size}`} key={name}>
          <Inputs
            autoFocus={
              blockIndex === 0 &&
              fieldsBlockIndex === 0 &&
              fieldIndex === 0
            }
            fieldSchema={fieldSchema}
            keys={name}
            labelIcon={labelIcon}
            metadatas={metadatas}
          />
        </div>
      );
    }
  )}
</div>

Now it’s time to go back to EditView container. Find there index.js file, go to the line with fieldsBlock mapping, and create a placeholder for our new component (this way you can be sure that everything was set up correctly):

<div className="row" key={fieldsBlockIndex}>
  {fieldsBlock.map(
    ({ name, size, fieldSchema, labelIcon, metadatas }, fieldIndex) => {
      const isComponent = fieldSchema.type === 'component';
      const hasCustomUI = fieldSchema['custom_ui'] ?? false;

      if (isComponent && !hasCustomUI) {
        const { component, max, min, repeatable = false } = fieldSchema;
        const componentUid = fieldSchema.component;

        return (
          <FieldComponent
            key={componentUid}
            componentUid={component}
            labelIcon={labelIcon}
            isRepeatable={repeatable}
            label={metadatas.label}
            max={max}
            min={min}
            name={name}
          />
        );
      }

      if(isComponent && hasCustomUI) {
        return <h1>THERE'S A PLACE FOR OUR NEW COMPONENT! </h1>
      }

      return (
        <div className={`col-${size}`} key={name}>
          <Inputs
            autoFocus={
              blockIndex === 0 &&
              fieldsBlockIndex === 0 &&
              fieldIndex === 0
            }
            fieldSchema={fieldSchema}
            keys={name}
            labelIcon={labelIcon}
            metadatas={metadatas}
          />
        </div>
      );
    }
  )}
</div>

…and the result:

The current view in the Recipe section


Are you still seeing the old version? Make sure that your app is running in watch mode. If that mode is not available for you — delete .cache&build folders and re-run your application every time you want to see the change you’ve made.


Preparation

At this point, it’s good to distinguish the most important functionalities related to our component:

  1. It should display a list of all available fruits (in the form of the fruit’s name with a checkbox)

  2. We want to be able to check/uncheck all fields at once

  3. Selecting/unselecting given fruit should not affect the Recipe model’s data (unless we press save)

That’s it. Now we’re ready to start working on our component!  🎉

Creating the component

For this part, I’ll use components from buffetjs (already installed in strapi) to speed up the process a little bit.

If you want to get the list of all of the fruits that are available in your database, you need to make a request there. Create utils directory with fruits.js file:

import { request } from "strapi-helper-plugin";

export const getFruits = async () => await request("/fruits");

Inside the components section create the FruitsComponent folder. It will contain FruitsList directory, FruitsList.parts.js with a single fruit component & index.js where you can find the logic for displaying all of the fruits.

Let’s start with FruitsList.parts.js:

import React from 'react';
import PropTypes from 'prop-types';
import { Checkbox } from '@buffetjs/core';

export const Fruit = ({ fruit, label, checked }) => {
  const handleCheckboxChange = () => {}

  return (
    <Checkbox 
      name={fruit.name} 
      message={label} 
      value={checked} 
      onChange={handleCheckboxChange} 
    />
  );
};

Fruit.propTypes = {
  fruit: PropTypes.object,
  label: PropTypes.string,
  checked: PropTypes.bool,
};

Fruit.defaultProps = {
  fruit: {},
  label: '',
  checked:

Then, inside index.js in FruitsList directory:

import React from 'react';
import PropTypes from 'prop-types';
import { Fruit } from './FruitsList.parts.js';

export const FruitsList = ({ fruits, checkedFruits }) => {
  const hasFruits = fruits.length !== 0;

  if (!hasFruits) {
    return <p>No fruits available</p>;
  }

  return (
    <ul>
      {fruits.map((fruit, index) => {
        const key = `_fruit_${fruit.name}`;
        const label = `${index + 1}. ${fruit.name}`;
        const fieldIndex = checkedFruits.findIndex((item) => item?.id === fruit.id);
        const checked = fieldIndex !== -1;

        return (
          <Fruit 
            fruit={fruit} 
            key={key} 
            label={label} 
            checked={checked} 
            fieldIndex={fieldIndex} 
          />
        );
      })}
    </ul>
  );
};

FruitsList.propTypes = {
  fruits: PropTypes.array,
  checkedFruits: PropTypes.array,
};

FruitsList.defaultProps = {
  fruits: [],
  checkedFruits: [],
};

When everything is ready, we can now create FruitsComponent (FruitsComponent/index.js). As you can see, our array of checkedFruits comes from the componentValue passed on by the connect function (lines 9, 25 & 31):

import React, { memo, useState, useEffect } from 'react';
import isEqual from 'react-fast-compare';
import { Padded, Text, Checkbox } from '@buffetjs/core';
import { getFruits } from '../../utils/fruits';
import connect from '../FieldComponent/utils/connect';
import select from '../FieldComponent/utils/select';
import { FruitsList } from './FruitsList';

const FruitComponent = ({ componentValue }) => {
  const [fruits, setFruits] = useState([]);
  const hasComponentValue = componentValue !== null;

  useEffect(() => {
    getFruits().then(setFruits);
  }, []);

  if (!hasComponentValue && fruits.length === 0) {
    return null;
  }

  return (
    <Padded top left right bottomsize="smd">
      <Text fontWeight="bold">Fruits List</Text>
      <Checkbox message="Select All" name="selectAll" />
      <FruitsList fruits={fruits} checkedFruits={componentValue?.fruits} />
    </Padded>
  );
};

const Memoized = memo(FruitComponent, isEqual);
export default connect(Memoized, select);

Now it’s finally time to update EditView container:

import { useContentManagerEditViewDataManager } from 'strapi-helper-plugin';

export const useFruitField = ({ fieldIndex }) => {
  const { addRelation, onRemoveRelation } = useContentManagerEditViewDataManager();

  const FIELD_NAME = 'fruits_list.fruits';

  const add = (fruit) => {
    const { id, name } = fruit;
    addRelation({
      target: {
        name: FIELD_NAME,
        value: [{ label: name, value: { id, name } }],
      },
    });
  };

  const remove = (fieldIndex) => onRemoveRelation(`${FIELD_NAME}.${fieldIndex}`);

  return { add, remove };
};

And a quick check of what our Recipe looks like in the application:

Recipe section


Right now, clicking on checkboxes does not change anything. But don’t worry — we’re halfway through!


Creating hooks & subscribing to events

The main difficulty we need to face right now is changing the state in a way that would trigger the save button (placed in the top right corner of our CMS page). In the FruitsComponent/FruitsList directory create FruitsList.hooks.js file:

import { useContentManagerEditViewDataManager } from 'strapi-helper-plugin';

export const useFruitField = ({ fieldIndex }) => {
  const { addRelation, onRemoveRelation } = useContentManagerEditViewDataManager();

  const FIELD_NAME = 'fruits_list.fruits';

  const add = (fruit) => {
    const { id, name } = fruit;
    addRelation({
      target: {
        name: FIELD_NAME,
        value: [{ label: name, value: { id, name } }],
      },
    });
  };

  const remove = (fieldIndex) => onRemoveRelation(`${FIELD_NAME}.${fieldIndex}`);

  return { add, remove };
};

Imported useContentManagerEditViewDataManager is a built-in hook that contains all available methods from EditViewDataManagerProvider. Since we are not dealing with more complicated structures (such as nested components and repeatable fields), we can use the basic ones — addRelation and onRemoveRelation. Do you remember when we created our single type  —  Recipe? Take a look at the screenshot below— this is where our FIELD_NAME comes from.

Now, when everything is ready, we can update FruitsList.parts.js:

import { useFruitField } from './FruitsList.hook';

export const Fruit = ({ fruit, label, checked, fieldIndex }) => {
  const { add, remove } = useFruitField();

  const handleCheckboxChange = () => (checked ? remove(fieldIndex) : add(fruit));

  return (
    <Checkbox 
      name={fruit.name} 
      message={label} 
      value={checked} 
      onChange={handleCheckboxChange} />
  );
};

Fruit.propTypes = {
  fruit: PropTypes.object,
  label: PropTypes.string,
  fieldIndex: PropTypes.number,
  checked: PropTypes.bool,
};

And voilà!

Here you can find the complete code of the content-manager with topics that were not covered in this article — logic for selecting all fruits at once and displaying fruit’s description on hover.


Final thoughts

I know that the above steps were not the easiest, but hooray, you made it! 🙌   And while we are still waiting for this feature to be released in one of the upcoming versions of Strapi, it’s good to have some alternatives for the older ones.

I highly encourage you to play with customising your fields even more— it might be a good starting point for more complex solutions and components (such as maps, colour-pickers or even custom plugins).

And don’t forget to share your final result with us on Twitter. We look forward to seeing your custom fields in action!


Last but not least, special thanks to my colleague, Kacper Łukawski, for mentoring and teaching me how to “make the impossible possible”.

Start a project

Are you a changemaker? Elevate your vision with a partnership dedicated to amplifying your impact!

💌 Join our newsletter

Receive insightful, innovator-focused content from global product experts — directly in your mail box, always free

Address & company info


Chmielna 73B / 14,
00-801 Warsaw, PL

VAT-EU (NIP): PL7831824606
REGON: 387099056
KRS: 0000861621

💌 Join our newsletter

Receive insightful, innovator-focused content from global product experts — directly in your mail box, always free

Address & company info


Chmielna 73B / 14,
00-801 Warsaw, PL

VAT-EU (NIP): PL7831824606
REGON: 387099056
KRS: 0000861621

💌 Join our newsletter

Receive insightful, innovator-focused content from global product experts — directly in your mail box, always free

Address & company info


Chmielna 73B / 14,
00-801 Warsaw, PL

VAT-EU (NIP): PL7831824606
REGON: 387099056
KRS: 0000861621