Strapi V4 populate Media and Dynamiczones from Components

Hello,

in my article schema i have a dynamiczone with 3 components blog.image, blog.rich-text and blog.gallery. When i call the rest api by “/articles?populate=*” i dont get the full data from those components. The nested images and dynamic zones are missing.

Am i doing something wrong or is it a bug?

This is my article schema:

{
  "kind": "collectionType",
  "collectionName": "articles",
  "info": {
    "singularName": "article",
    "pluralName": "articles",
    "displayName": "Articles",
    "description": ""
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {},
  "attributes": {
    "title": {
      "type": "string"
    },
    "content": {
      "type": "dynamiczone",
      "components": [
        "blog.image",
        "blog.rich-text",
        "blog.gallery"
      ],
      "required": false
    },
    "category": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::article-category.article-category",
      "inversedBy": "articles"
    },
    "slug": {
      "type": "string"
    },
    "image": {
      "type": "media",
      "multiple": false,
      "required": true,
      "allowedTypes": [
        "images",
        "files",
        "videos"
      ]
    },
    "author": {
      "type": "relation",
      "relation": "manyToOne",
      "target": "api::editor.editor",
      "inversedBy": "articles"
    },
    "intro": {
      "type": "text",
      "required": true
    }
  }
}

The nested image component:

{
  "collectionName": "components_image_images",
  "info": {
    "displayName": "Image",
    "icon": "image",
    "description": ""
  },
  "options": {},
  "attributes": {
    "data": {
      "type": "media",
      "multiple": false,
      "required": false,
      "allowedTypes": [
        "images",
        "files",
        "videos"
      ]
    },
    "caption": {
      "type": "string",
      "required": true
    }
  }
}

The nested gallery component:

{
  "collectionName": "components_gallery_galleries",
  "info": {
    "displayName": "Gallery",
    "icon": "images",
    "description": ""
  },
  "options": {},
  "attributes": {
    "images": {
      "type": "component",
      "repeatable": true,
      "component": "blog.image"
    },
    "title": {
      "type": "string"
    }
  }
}

System Information
  • Strapi Version: 4.0.0-beta.12
  • Operating System: macOS Big Sur 11.5.2
  • Database: PostgreSQL
  • Node Version: 12.20.1
  • Yarn Version: 3.1.0

No this is normal as you’ll need to populate all the component levels manually. We are looking at additional wildcard solutions like *.* ect in this RFC: REST API by alexandrebodin · Pull Request #27 · strapi/rfcs · GitHub

And we plan to add some more examples on population for dyn zones and components in the documentation soon

3 Likes

We had the same problem with deeply nested components in dynamiczones. After a bit of fiddling, this is what we came up with.

Created a new file src/helpers/populate.js

const { createCoreController } = require("@strapi/strapi/lib/factories");

function populateAttribute({ components }) {
  if (components) {
    const populate = components.reduce((currentValue, current) => {
      return { ...currentValue, [current.split(".").pop()]: { populate: "*" } };
    }, {});
    return { populate };
  }
  return { populate: "*" };
}

const getPopulateFromSchema = function (schema) {
  return Object.keys(schema.attributes).reduce((currentValue, current) => {
    const attribute = schema.attributes[current];
    if (!["dynamiczone", "component"].includes(attribute.type)) {
      return currentValue;
    }
    return {
      ...currentValue,
      [current]: populateAttribute(attribute),
    };
  }, {});
};

function createPopulatedController(uid, schema) {
  return createCoreController(uid, () => {
    console.log(schema.collectionName, getPopulateFromSchema(schema));
    return {
      async find(ctx) {
        ctx.query = {
          ...ctx.query,
          populate: getPopulateFromSchema(schema),
        };
        return await super.find(ctx);
      },
      async findOne(ctx) {
        ctx.query = {
          ...ctx.query,
          populate: getPopulateFromSchema(schema),
        };
        return await super.findOne(ctx);
      },
    };
  });
}

module.exports = createPopulatedController;

then in controllers needing to be deeply populated:

"use strict";

/**
 *  homepage controller
 */

const schema = require("../content-types/homepage/schema.json");
const createPopulatedController = require("../../../helpers/populate");

module.exports = createPopulatedController("api::homepage.homepage", schema);

What it basically does, is overriding the default controller by dynamically creating a “populate” query from the content type schema. I hope it might help someone…

6 Likes

Is there are any other ideas how to populate nested components?
I have a single-type with two dynamic zones, every dynamic zone component have two image attributes:
catalogImage and detailedImages.
I tried solution above, it didn’t work at all…
Also tried: api/available-today?populate=top.catalogImage.detailedImages, got catalogImage but not detailedImages attribute.

What’s wrong with it?

Invested a lot of time in development on Strapi CMS , but can’t get data that I need.
Please help.

If trying to get a data with: /api/available-today?populate=top.catalogImage.detailedImage

will get only this:

{
“data”: {
“id”: 1,
“attributes”: {
“createdAt”: “2021-12-25T04:00:07.615Z”,
“updatedAt”: “2021-12-25T04:00:08.843Z”,
“publishedAt”: “2021-12-25T04:00:08.840Z”,
“top”: [
{
“id”: 3,
“__component”: “available-today.mousse-cake”,
“Name”: “Mousse Cake”,
“description”: “”,
“price”: 50,
“inStockCount”: 1,
“priority”: 500,
“catalogImage”: {
“data”: {
“id”: 35,
“attributes”: {
“name”: “red cake.jpeg”,
“alternativeText”: “red cake.jpeg”,
“caption”: “red cake.jpeg”,
“width”: 1080,
“height”: 1311,
“formats”: {
“large”: {
“ext”: “.jpeg”,
“url”: “/uploads/large_red_cake_040eeb6e6a.jpeg”,
“hash”: “large_red_cake_040eeb6e6a”,
“mime”: “image/jpeg”,
“name”: “large_red cake.jpeg”,
“path”: null,
“size”: 75.01,
“width”: 824,
“height”: 1000
},
“small”: {
“ext”: “.jpeg”,
“url”: “/uploads/small_red_cake_040eeb6e6a.jpeg”,
“hash”: “small_red_cake_040eeb6e6a”,
“mime”: “image/jpeg”,
“name”: “small_red cake.jpeg”,
“path”: null,
“size”: 26.16,
“width”: 412,
“height”: 500
},
“medium”: {
“ext”: “.jpeg”,
“url”: “/uploads/medium_red_cake_040eeb6e6a.jpeg”,
“hash”: “medium_red_cake_040eeb6e6a”,
“mime”: “image/jpeg”,
“name”: “medium_red cake.jpeg”,
“path”: null,
“size”: 48.72,
“width”: 618,
“height”: 750
},
“thumbnail”: {
“ext”: “.jpeg”,
“url”: “/uploads/thumbnail_red_cake_040eeb6e6a.jpeg”,
“hash”: “thumbnail_red_cake_040eeb6e6a”,
“mime”: “image/jpeg”,
“name”: “thumbnail_red cake.jpeg”,
“path”: null,
“size”: 4.57,
“width”: 129,
“height”: 156
}
},
“hash”: “red_cake_040eeb6e6a”,
“ext”: “.jpeg”,
“mime”: “image/jpeg”,
“size”: 109.83,
“url”: “/uploads/red_cake_040eeb6e6a.jpeg”,
“previewUrl”: null,
“provider”: “local”,
“provider_metadata”: null,
“createdAt”: “2021-12-25T02:10:51.232Z”,
“updatedAt”: “2021-12-25T02:10:51.232Z”
}
}
}
}
]
}
},
“meta”: {}
}

Hello Alex,

Can you create a custom controller and try using the advanced populating section of the Entity Service API. I will soon update with a repo If I can get it working. For strapi v3 there is a plugin (strapi-deepsearch-service), which badly needs to be updated.

Thanks for the answer!
I already start digging in that direction, but I still didn’t get one thing…

So, as I said earlier I have a single-type with two dynamic zones with different components in it.
My components named like this: available-today.bla-bla-bla
How can I use these names with entityService to customize the output from the api?

Let’s say I created an entityService like this:

const entry = await strapi.entityService.findMany(
        "api::available-today.available-today",
        {
          populate: {
            top: true,
            bottom: true,
            ["available-today.mousse-cake"]: {
              fields: ["catalogImage", "detailedImages"],
              populate: true,
            },
          },
        }
      );

but this didn’t work…

System information:
Strapi version: v4.0.0
Database: postgresql 14
Node: 14.17

Is there a way to populate data of multiple media attributes from a component within a dynamic zone?

Ok so I figured it out. First, make sure to update your strapi to the latest version (4.0.2) since my repo is on that version.

Introduction

I will explain and provide you with an example of how you can deeply populate nested components and dynamiz zones in the v4 of Strapi.

Structure

  • I have created a single type content named layout with 2 dynamic zones and some nested fields and components to them
    • Header (Dynamic Zone)
      • Logo (Component)
    • Sidebar (Dynamic Zone)
      • Avatar (Component)
        • name
        • img
      • Menu (Component)
        • heading
        • links (Repeatable Component)
          • title
          • icon

Then I enabled the find controller for this content type from the setting.

Normally the API would return this data for this model by default:

{
    "data": {
        "id": 1,
        "attributes": {
            "createdAt": "2021-12-25T07:35:17.286Z",
            "updatedAt": "2021-12-25T08:03:48.671Z",
            "publishedAt": "2021-12-25T07:35:18.676Z"
        }
    },
    "meta": {}
}

But we can overwrite/extend the core find controller (you can also create a new route/controller) instead.

In order to do this, modify the code in the following file:

src\api\layout\controllers\layout.js
'use strict';

/**
 *  layout controller
 */

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

module.exports = createCoreController('api::layout.layout', ({ strapi }) => ({
    async find(ctx) {
        const { query } = ctx;

        const entity = await strapi.entityService.findMany('api::layout.layout', {
            ...query,
            populate: {
                header: {
                    populate: {
                        img: true
                    }
                },
                sidebar: {
                    populate: {
                        img: true, links: {
                            populate: {
                                icon: true
                            }
                        }
                    }
                },
            },
        });
        const sanitizedEntity = await this.sanitizeOutput(entity, ctx);

        return this.transformResponse(sanitizedEntity);

    }
}));

Pay attention to the api::layout.layout which is the UID for your specific content type and field.

Now that looks like the data we are looking for:

Now of course, you should review the core controllers and your business logic to make sure you are handling permissions and parameter passing correctly and necessarily… but this should get you started!

Here is a repo: https://github.com/nextrapi/Strapi_v4_Deep_Populate

All the best!

8 Likes

Oh, thank you so much!
You are the best!

Great work @thylo: this worked like a charm.

Quite frankly, I am surprised Strapi released v4 without this functionality for deeply nested dynamic zones utilizing ?populate=*, as it renders the Content-Type Builder GUI natively impotent of the utility of nested dynamic zones.

For anyone wishing to implement this improvement, the “strapi develop” script will need to restarted to see the effect of the quoted “populate.js” helper.

For anyone who needs to modify multiple controllers to utilize the “populate.js” helper, the addition of a template literal helps marginally speed up the process:

"use strict";

/**
 *  homepage controller
 */
const collectionType = 'homepage'

const schema = require(`../content-types/${collectionType}/schema.json`);
const createPopulatedController = require("../../../helpers/populate");

module.exports = createPopulatedController(`api::${collectionType}.${collectionType}`, schema);

“A good programmer is a lazy programmer”

4 Likes

@thylo

Your solution works well, however, I have the case of a component with a component.
How would I handle that?

It works fine. but a problem with the media component. I am unable to get media in the json

Uh oh. This gives us a real stomach ache.
This should be out-of-the-box much easier, especially if this is the recommended release to use.
If you are wanting a single item, most of the time you just want the all the content. This should just be standard. We don’t want to start hacking controllers for every component/collection.
For the time being, we are going the graphql route. Have to hard type attributes, but it seems more straight forward.
Seems this is not developed enough to use out of the box. Too bad!

Correction: adding the code from @nextrapi works (easier than typing out the graphql attributes), but still seems crazy that we will have to add this hack to our controllers for collections.…, and then update it when we update attributes, dynamic zones, etc.
Can we please have a better solution here? Seems like this could break in an update down the road.
Hence the stomach ache we feel about this.

4 Likes

@GdM

I was able to populate nested repeatable component by small expansion of populateAttribute function to:

const populateAttribute = ({ components }) => {
  if (components) {
    const populate = components.reduce((currentValue, current) => {
      const [componentDir, componentName] = current.split('.');

      /* Component attributes needs to be explicitly populated */
      const componentAttributes = Object.entries(
        require(`../components/${componentDir}/${componentName}.json`).attributes,
      ).filter(([, v]) => v.type === 'component');

      const attrPopulates = componentAttributes.reduce(
        (acc, [curr]) => ({ ...acc, [curr]: { populate: '*' } }),
        {},
      );

      return { ...currentValue, [current.split('.').pop()]: { populate: '*' }, ...attrPopulates };
    }, {});
    return { populate };
  }
  return { populate: '*' };
};
2 Likes

For anyone wishing to populate all attributes (à la Strapi v3) with ?populate=*, here’s a merge of the solutions provided by @thylo, @JayTee, and myself.


src/helpers/populate.js

/*********************************
 * Poplulate All Attributes
 *
 * https://forum.strapi.io/t/strapi-v4-populate-media-and-dynamiczones-from-components/12670/2
 *
 *********************************/

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


const populateAttribute = ({ components }) => {
  if (components) {
    const populate = components.reduce((currentValue, current) => {
      const [componentDir, componentName] = current.split('.')

      /* Component attributes needs to be explicitly populated */
      const componentAttributes = Object.entries(
        require(`../components/${componentDir}/${componentName}.json`)
          .attributes
      ).filter(([, v]) => v.type === 'component')

      const attrPopulates = componentAttributes.reduce(
        (acc, [curr]) => ({ ...acc, [curr]: { populate: '*' } }),
        {}
      )

      return {
        ...currentValue,
        [current.split('.').pop()]: { populate: '*' },
        ...attrPopulates,
      }
    }, {})
    return { populate }
  }
  return { populate: '*' }
}

const getPopulateFromSchema = function (schema) {
  // console.log('schema', schema)
  return Object.keys(schema.attributes).reduce((currentValue, current) => {
    const attribute = schema.attributes[current]
    if (!['dynamiczone', 'component'].includes(attribute.type)) {
      return { [current]: populateAttribute(attribute) }
    }
    return {
      ...currentValue,
      [current]: populateAttribute(attribute),
    }
  }, {})
}

function createPopulatedController(uid, schema) {
  return createCoreController(uid, () => {
    // console.log(schema.collectionName, getPopulateFromSchema(schema))
    return {
      async find(ctx) {
        // deeply populate all attributes with ?populate=*, else retain vanilla functionality
        if (ctx.query.populate === '*') {
          ctx.query = {
            ...ctx.query,
            populate: getPopulateFromSchema(schema),
          }
        }
        return await super.find(ctx)
      },
      async findOne(ctx) {
        if (ctx.query.populate === '*') {
          ctx.query = {
            ...ctx.query,
            populate: getPopulateFromSchema(schema),
          }
        }
        return await super.findOne(ctx)
      },
    }
  })
}

module.exports = createPopulatedController

src/api/homepage/controllers/homepage.js

"use strict";

/**
 *  homepage controller
 */

const collectionType = 'homepage'

const schema = require(`../content-types/${collectionType}/schema.json`);
const createPopulatedController = require("../../../helpers/populate");

module.exports = createPopulatedController(`api::${collectionType}.${collectionType}`, schema);
3 Likes

Does someone know if this could be done more easy. I need the audio from a nested episode:

3 Likes

So the code pasted by @sg-code works but it doesn’t seem to be pulling in my next level media data. How can we make it populate the next level?

Hey,
Here is a very small improvement of the last @sg-code suggestion, that allows to populate medias in repeatable relational components. I think it’s a rather specific need, maybe too specific for a global helper.

/*********************************
 * Poplulate All Attributes
 *
 * https://forum.strapi.io/t/strapi-v4-populate-media-and-dynamiczones-from-components/12670/2
 *
 *********************************/

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


const populateAttribute = (attr) => {
  const { components, repeatable } = attr;

  if (components) {
    const populate = components.reduce((currentValue, current) => {
      const [componentDir, componentName] = current.split('.');

      /* Component attributes needs to be explicitly populated */
      const componentAttributes = Object.entries(
        require(`../components/${componentDir}/${componentName}.json`)
          .attributes
      ).filter(([, v]) => v.type === 'component');

      const attrPopulates = componentAttributes.reduce(
        (acc, [curr]) => ({ ...acc, [curr]: { populate: '*' } }),
        {}
      );

      return {
        ...currentValue,
        [current.split('.').pop()]: { populate: '*' },
        ...attrPopulates,
      };
    }, {});

    return { populate };
  }

  else if (repeatable) {
    const componentName = attr.component.split('.')[1];
    const populate = { [componentName]: { populate: '*' } };
    return { populate };
  }
  return { populate: '*' };
};

const getPopulateFromSchema = function (schema) {
  //  console.log('schema', schema)
  return Object.keys(schema.attributes).reduce((currentValue, current) => {
    const attribute = schema.attributes[current];
    // console.log(attribute);
    if (!['dynamiczone', 'component'].includes(attribute.type)) {
      return { [current]: populateAttribute(attribute) };
    }
    return {
      ...currentValue,
      [current]: populateAttribute(attribute),
    };
  }, {});
};

function createPopulatedController(uid, schema) {
  return createCoreController(uid, () => {
    // console.log(schema.collectionName, getPopulateFromSchema(schema))
    return {
      async find(ctx) {
        // deeply populate all attributes with ?populate=*, else retain vanilla functionality
        if (ctx.query.populate === '*') {
          ctx.query = {
            ...ctx.query,
            populate: getPopulateFromSchema(schema),
          };
        }
        return await super.find(ctx);
      },
      async findOne(ctx) {
        if (ctx.query.populate === '*') {
          ctx.query = {
            ...ctx.query,
            populate: getPopulateFromSchema(schema),
          };
        }
        return await super.findOne(ctx);
      },
    };
  });
}

module.exports = createPopulatedController;
1 Like

check my last reply, it should work :wink: