Strapi V4 populate Media and Dynamiczones from Components

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.

5 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:

Just populate your body{} object , and you will get all components.
Hope this will help someone’s problem

And instead of YourPage add the name of your own page

const { createCoreController } = require("@strapi/strapi").factories;
module.exports = createCoreController(
“api::Yourpage.Your-page”,
({ strapi }) => ({
async find(ctx) {
const { query } = ctx;
const entity = await strapi.entityService.findMany(
“api::yourPage.yourPage”,
{
…query,
populate: {
body: {
populate: “*”,
},
},
}
);
console.log(entity);
const sanitizedEntity = await this.sanitizeOutput(entity, ctx);
return this.transformResponse(sanitizedEntity);
}

Hello, I had an issue for media in a component not populating so added this if you would like to check it over :slight_smile: ?

The below will now populate all components, repeatables and media by calling populate=* on an endpoint.

/*********************************
 * Poplulate All Attributes
 *
 * https://forum.strapi.io/t/strapi-v4-populate-media-and-dynamiczones-from-components/12670/2
 * 
 * Updates
 * Line 24: Added media type handling
 *
 *********************************/

const { createCoreController } = require("@strapi/strapi/lib/factories");
const {
  default: entityService,
} = require("@strapi/strapi/lib/services/entity-service");

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
      )
        /** Implemented media type */
      const attrPopulates = componentAttributes.filter(([, v]) => {
        return ["component", "media"].includes(v.type)
      }).reduce(
        (acc, [curr, obj]) => { 
          switch (obj.type) {
            case "component" :
                return  { ...acc, [curr]: { populate: "*" } };
            case "media":
                return  { ...acc, [curr]: "*" };
          }
        },
        {}
      );

      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) {
  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, () => {

    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;


2 Likes

Hello,

I didn’t find this populated a relation in a component so used and updated Hugofgx9’s solution

I am getting the following error:

Invalid populate field. Expected a relation, got string

My schema is the following:

{
  "kind": "collectionType",
  "collectionName": "page_slices",
  "info": {
    "singularName": "page-slice",
    "pluralName": "page-slices",
    "displayName": "Pages",
    "description": ""
  },
  "options": {
    "draftAndPublish": true
  },
  "pluginOptions": {
    "versions": {
      "versioned": true
    },
    "i18n": {
      "localized": true
    }
  },
  "attributes": {
    "PageName": {
      "type": "string",
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      }
    },
    "SEO": {
      "type": "component",
      "repeatable": false,
      "component": "shared.seo",
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      }
    },
    "Slug": {
      "type": "string",
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      }
    },
    "Module": {
      "type": "dynamiczone",
      "components": [
        "shared.home-hero",
        "shared.home-logos",
        "shared.inner-hero",
        "shared.rich-text-editor",
        "shared.seperator"
      ],
      "pluginOptions": {
        "i18n": {
          "localized": true
        }
      }
    }
  }
}

Any more updates on this? put this in my project and added the controller in my collection controller, doesn’t work.

1 Like

For those still struggling with this, we use this simple customization to the controller find() function for an entity’s dynamic zones and content like below (this entity is a Page we created, so it is the controller for this ./src/api/page/controllers/page.js).
(You will need to augment the populate fields with the names of your own repeatable components or media attributes, and add to it as you add new sub-components to the entity… bit annoying but it has worked so far for our needs…)

async find(ctx) {
    const { query } = ctx;

    const entity = await strapi.entityService.findMany("api::page.page", { // page.page is our entity here
      ...query, // attach any incoming queries already in the request
      populate: {
        Image: true, // get data for media item field called "Image"
        Content: {// a dynamic zone with different components, 
// and those components might have some repeatable sub-component too
// We only seem to need to add the sub-components...
          populate: {
            Image: true,// media field called "Image"
            Buttons: {// repeatable sub-component called "Buttons" used in a dynamic zone component
              populate: {
                page: true,// the Button component has a relation to a page item, so populate that...
              },
            },
            Icons: true,// another repeatable sub-component used in a dynamic zone component
          },
        },
      },
    });
    const sanitizedEntity = await this.sanitizeOutput(entity, ctx);

    return this.transformResponse(sanitizedEntity);
  },
4 Likes

Hello everyone, I am sharing with you my solution for nested relationships. My entity “home-setting” has two attributes of type “media” (“image” and “logo”) and looks like:
“home-setting”: {
“interval”,
“slides”: {
“image”: { … },
“logo”: {…}
}
}.
In my home-setting “services”, when writing only this:

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

module.exports = createCoreService(‘api::home-setting.home-setting’, ({strapi}) =>({
async find(params, module) {
return await strapi.query(‘api::home-setting.home-setting’).findOne({
populate: true
)}
}));

I was able to just fetch a slide, with neither image nor code. Instead, following nextrapi example, I added this lines:

module.exports = createCoreService(‘api::home-setting.home-setting’, ({strapi}) =>({

async find(params, module) {
return await strapi.query(‘api::home-setting.home-setting’).findOne({
populate: {
slides: {
populate: {
image: true,
icon: true
}
}
},
})
}
}));

This works fine for me, with no need of customizing controllers.

1 Like

Currently using this version of the populate helper:

populate.js
/*********************************
 * Poplulate All Attributes
 *
 * https://forum.strapi.io/t/strapi-v4-populate-media-and-dynamiczones-from-components/12670/2
 *
 * Updates
 * Line 24: Added media type handling
 *
 *********************************/

const { createCoreController } = require("@strapi/strapi/lib/factories");
const {
  default: entityService,
} = require("@strapi/strapi/lib/services/entity-service");

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
      );
      /** Implemented media type */
      const attrPopulates = componentAttributes
        .filter(([, v]) => {
          return ["component", "media"].includes(v.type);
        })
        .reduce((acc, [curr, obj]) => {
          switch (obj.type) {
            case "component":
              return { ...acc, [curr]: { populate: "*" } };
            case "media":
              return { ...acc, [curr]: "*" };
          }
        }, {});

      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) {
  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, () => {
    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;

It is not populating the Team Member relationship at this level:

  • Page - collection
    • Section - dynamic zone
      • Team - component
        • Team Category - repeating component
          • Team Member - relationship field
Current data output
{
  "Sections": [
    // ...other sections here
    {
      "id":1,
      "__component":"sections.team",
      "Title":"The Team",
      "Subtitle":"Your Expert Team",
      "color":"Dark",
      "teamCategory": [
        {
          "id":3,
          "categoryTitle":"Sales & Leasing",
          // Should have teamCategory relation here.
        },
        {
          "id":4,
          "categoryTitle":"Marketing",
          // Should have teamCategory relation here.
        }
      ]
    }
  ]
}

Any help would be great. This is getting very frustrating.

Here’s a modification of @urbandale’s solution, which includes the code for overwriting the findOne controller, and maintains default functionality for API requests which do not include populate=*.

This seems to be the best working solution for v4.1.7.

Please see @urbandale’s post for more context.

./src/api/page/controllers/page.js

'use strict'

/**
 *  page controller
 *
 *  info: https://forum.strapi.io/t/strapi-v4-populate-media-and-dynamiczones-from-components/12670/26
 */

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

// declare the uid for the controller
const uid = 'api::page.page'

// see @urbandale's post for context: https://forum.strapi.io/t/strapi-v4-populate-media-and-dynamiczones-from-components/12670/26
const components = {
  Image: true, // get data for media item field called "Image"
  Content: {
    // a dynamic zone with different components,
    // and those components might have some repeatable sub-component too
    // We only seem to need to add the sub-components...
    populate: {
      Image: true, // media field called "Image"
      Buttons: {
        // repeatable sub-component called "Buttons" used in a dynamic zone component
        populate: {
          page: true, // the Button component has a relation to a page item, so populate that...
        },
      },
      Icons: true, // another repeatable sub-component used in a dynamic zone component
    },
  },
}

module.exports = createCoreController(uid, () => {
  return {
    async find(ctx) {
      // overwrite default populate=* functionality
      if (ctx.query.populate === '*') {
        const entity = await strapi.entityService.findMany(uid, {
          ...ctx.query,
          populate: components,
        })
        const sanitizedEntity = await this.sanitizeOutput(entity, ctx)

        return this.transformResponse(sanitizedEntity)
      }
      // maintain default functionality for all other request
      return super.find(ctx)
    },
    async findOne(ctx) {

      const { id } = ctx.request.params

      if (ctx.query.populate === '*') {
        const entity = await strapi.entityService.findOne(uid, id, {
          ...ctx.query,
          populate: components,
        })
        const sanitizedEntity = await this.sanitizeOutput(entity, ctx)

        return this.transformResponse(sanitizedEntity)
      }

      return super.findOne(ctx)
    },
  }
})

1 Like

As we already know we can populate by field like “/articles?populate=tags,author”. But, if we have a media filed in author table (lets say an ‘avatar’ image) , it will not be returned in response.

To fix this, we can just access it like this, “/articles?populate=tags,author.avatar” where avatar is the media field we want to populate in our nested author object.

I do it for selective populate use cases. Been bummed about it for couple of days and found out this works by trial and error (smashes_head)

5 Likes