Save relation items order (Issue #2166)

Hello guys, do you have a solution to the Github Issue #2166 (https://github.com/strapi/strapi/issues/2166) or maybe an alternative solution to fix a manual selected order for relations items?

5 Likes

Hello,

No we don’t currently have a solution or a workaround for this at the moment. We are planning a database refresh for this quarter and will be adjusting the population code and likely refactoring some of the relational connections, but these changes will be made into a v4 branch.

For some info as to why they aren’t maintaining their order see: Troubleshooting - Strapi Developer Documentation

In the meantime: would it be reasonable to remove the reorder ui functionality? I think it’s giving the wrong impression, especially to my clients :grimacing:

2 Likes

Here’s a workaround that works for us:

  • If you haven’t already, create your ‘parent’ entity type that will refer to the relations
  • Add a Component Collection field to the content type, name it the plural of the other content type
  • Choose to create a new Component for this Component Collection
  • Add a single field to the Component, which is a 1:1 relationship to another Content Type, i.e. the one you want to assign ordered relations of
  • Save the new content type (Strapi restarts)
  • Create a new item of the new type
  • Fill the component collection with components, each with a the foreign content type’s item of your choosing, assigned as the only relation
  • Change the order in the component collection as you wish
  • Save
  • Use REST or the GraphQL playground to retrieve the data, and notice the order is respected
4 Likes

Hello everyone,
We won’t be able to fix this issue without major changes.

How do you feel if we decide to disable it?

I also ran into this for one of our clients. We managed to play around with the lifecycles and perform a raw query like this in the beforeUpdate:

await strapi.connections.default.raw(`DELETE FROM ${model}s__blocks WHERE ${model}_id = ${data.id};`);

Our relation here was “blocks”. Basically, what we are doing is clearing all the rows before save and re-save them after the post is saved. It’s not pretty, but it served it’s purpose. Please be careful though as if the beforeUpdate has ran, but something went wrong after that, you could possibly removing some data.

Disable what?

Disable the manual ordering of relations

Oh yeah definitely, if it isn’t working (they way people expect it to), might as well not show it.

1 Like

Agreed here as well, as it’s a question I see often.

In fact, the manual sorting function is very important for many businesses, such as some widget and e-commerce applications. I hope it can be realized in the future, which makes the content management more flexible and natural

2 Likes

i have a fix for this,
should I make a merge request?
but sorry, I only made this for knex (MySQL driver extensions/connector-bookshelf/lib/relations.js


async update(params, { transacting } = {}) {

//...

// line 221

case 'manyToMany': {

//...

update function as of 3.4.5

async update(params, { transacting } = {}) {
    const relationUpdates = [];
    const primaryKeyValue = getValuePrimaryKey(params, this.primaryKey);
    const response = await module.exports.findOne.call(this, params, null, {
      transacting,
    });

    // Only update fields which are on this document.
    const values = Object.keys(removeUndefinedKeys(params.values)).reduce((acc, current) => {
      const property = params.values[current];
      const association = this.associations.filter(x => x.alias === current)[0];
      const details = this._attributes[current];

      if (!association && _.get(details, 'isVirtual') !== true) {
        return _.set(acc, current, property);
      }

      const assocModel = strapi.db.getModel(details.model || details.collection, details.plugin);

      switch (association.nature) {
        case 'oneWay': {
          return _.set(acc, current, _.get(property, assocModel.primaryKey, property));
        }
        case 'oneToOne': {
          if (response[current] === property) return acc;

          if (_.isNull(property)) {
            const updatePromise = assocModel
              .where({
                [assocModel.primaryKey]: getValuePrimaryKey(
                  response[current],
                  assocModel.primaryKey
                ),
              })
              .save(
                { [details.via]: null },
                {
                  method: 'update',
                  patch: true,
                  require: false,
                  transacting,
                }
              );

            relationUpdates.push(updatePromise);
            return _.set(acc, current, null);
          }

          // set old relations to null
          const updateLink = this.where({ [current]: property })
            .save(
              { [current]: null },
              {
                method: 'update',
                patch: true,
                require: false,
                transacting,
              }
            )
            .then(() => {
              return assocModel.where({ [this.primaryKey]: property }).save(
                { [details.via]: primaryKeyValue },
                {
                  method: 'update',
                  patch: true,
                  require: false,
                  transacting,
                }
              );
            });

          // set new relation
          relationUpdates.push(updateLink);
          return _.set(acc, current, property);
        }
        case 'oneToMany': {
          // receive array of ids or array of objects with ids

          // set relation to null for all the ids not in the list
          const currentIds = response[current];
          const toRemove = _.differenceWith(currentIds, property, (a, b) => {
            return `${a[assocModel.primaryKey] || a}` === `${b[assocModel.primaryKey] || b}`;
          });

          const updatePromise = assocModel
            .where(
              assocModel.primaryKey,
              'in',
              toRemove.map(val => val[assocModel.primaryKey] || val)
            )
            .save(
              { [details.via]: null },
              {
                method: 'update',
                patch: true,
                require: false,
                transacting,
              }
            )
            .then(() => {
              return assocModel
                .where(
                  assocModel.primaryKey,
                  'in',
                  property.map(val => val[assocModel.primaryKey] || val)
                )
                .save(
                  { [details.via]: primaryKeyValue },
                  {
                    method: 'update',
                    patch: true,
                    require: false,
                    transacting,
                  }
                );
            });

          relationUpdates.push(updatePromise);
          return acc;
        }
        case 'manyToOne': {
          return _.set(acc, current, _.get(property, assocModel.primaryKey, property));
        }
        case 'manyWay':
        case 'manyToMany': {
          const storedValue = transformToArrayID(response[current]);
          const currentValue = transformToArrayID(params.values[current]);

          /** BEGIN custom modify by james
           * modify to remove due to sorting issue **/
          let toRemove = [];
          let toAdd = [];
          for (let c = 0; c < storedValue.length; c++) {
            const vStored = storedValue[c];
            const vCurrent = currentValue.length > c ? currentValue[c] : null;

            console.debug('relation.update', current, 'storedValue', vStored, 'newValue', vCurrent);
            if (vCurrent == null || toRemove.length > 0) {
              toRemove.push(vStored);
              continue;
            }
            if (vCurrent !== vStored) {
              toRemove.push(vStored);
              continue;
            }
          }
          for(let c = storedValue.length - toRemove.length; c < currentValue.length; c++) {
            toAdd.push(currentValue[c]);
          }
          console.debug(current, "toRemove", toRemove, "toAdd:", toAdd);
          // original
          // const toAdd = _.difference(currentValue, storedValue);
          // const toRemove = _.difference(storedValue, currentValue);
          /** END custom modify **/
          const collection = this.forge({
            [this.primaryKey]: primaryKeyValue,
          })[association.alias]();
          console.debug("toRemove", toRemove, "attaching", toAdd);
          const updatePromise = collection
            .detach(toRemove, { transacting })
            .then(() => collection.attach(toAdd, { transacting }));

          relationUpdates.push(updatePromise);
          return acc;
        }
        // media -> model
        case 'manyMorphToMany':
        case 'manyMorphToOne': {
          // Update the relational array.
          const refs = params.values[current];

          if (Array.isArray(refs) && refs.length === 0) {
            // clear related
            relationUpdates.push(
              removeRelationMorph(this, { params: { id: primaryKeyValue }, transacting })
            );
            break;
          }

          refs.forEach(obj => {
            const targetModel = strapi.db.getModel(
              obj.ref,
              obj.source !== 'content-manager' ? obj.source : null
            );

            const reverseAssoc = targetModel.associations.find(assoc => assoc.alias === obj.field);

            // Remove existing relationship because only one file
            // can be related to this field.
            if (reverseAssoc && reverseAssoc.nature === 'oneToManyMorph') {
              relationUpdates.push(
                removeRelationMorph(this, {
                  params: {
                    alias: association.alias,
                    ref: targetModel.collectionName,
                    refId: obj.refId,
                    field: obj.field,
                  },
                  transacting,
                }).then(() =>
                  addRelationMorph(this, {
                    params: {
                      id: response[this.primaryKey],
                      alias: association.alias,
                      ref: targetModel.collectionName,
                      refId: obj.refId,
                      field: obj.field,
                      order: 1,
                    },
                    transacting,
                  })
                )
              );

              return;
            }

            const addRelation = async () => {
              const maxOrder = await this.morph
                .query(qb => {
                  qb.max('order as order').where({
                    [`${association.alias}_id`]: obj.refId,
                    [`${association.alias}_type`]: targetModel.collectionName,
                    field: obj.field,
                  });
                })
                .fetch({ transacting });

              const { order = 0 } = maxOrder.toJSON();

              await addRelationMorph(this, {
                params: {
                  id: response[this.primaryKey],
                  alias: association.alias,
                  ref: targetModel.collectionName,
                  refId: obj.refId,
                  field: obj.field,
                  order: order + 1,
                },
                transacting,
              });
            };

            relationUpdates.push(addRelation());
          });
          break;
        }
        // model -> media
        case 'oneToManyMorph':
        case 'manyToManyMorph': {
          const currentValue = transformToArrayID(params.values[current]);

          const model = strapi.db.getModel(details.collection || details.model, details.plugin);

          const promise = removeRelationMorph(model, {
            params: {
              alias: association.via,
              ref: this.collectionName,
              refId: response.id,
              field: association.alias,
            },
            transacting,
          }).then(() => {
            return Promise.all(
              currentValue.map((id, idx) => {
                return addRelationMorph(model, {
                  params: {
                    id,
                    alias: association.via,
                    ref: this.collectionName,
                    refId: response.id,
                    field: association.alias,
                    order: idx + 1,
                  },
                  transacting,
                });
              })
            );
          });

          relationUpdates.push(promise);

          break;
        }
        case 'oneMorphToOne':
        case 'oneMorphToMany': {
          break;
        }
        default:
      }

      return acc;
    }, {});

    await Promise.all(relationUpdates);

    delete values[this.primaryKey];
    if (!_.isEmpty(values)) {
      await this.forge({
        [this.primaryKey]: getValuePrimaryKey(params, this.primaryKey),
      }).save(values, {
        patch: true,
        transacting,
      });
    }

    const result = await this.forge({
      [this.primaryKey]: getValuePrimaryKey(params, this.primaryKey),
    }).fetch({
      transacting,
    });

    return result && result.toJSON ? result.toJSON() : result;
  },

@James_Tan it might be worth it, if we could find someone in the community who is adapt at mongoDB they could add code for mongoose.

Related to this. Saving order works fine in one direction (Journeys has Places) with M:N relations, but GraphQL doest not respect the order. (Sorting fails to work same time with Places has Journeys.)

  1. Create content type Journey
  2. Create content type Place
  3. Add M:N relation between Journey and Place
  4. Create Places A, B and C
  5. Create Journey and add A, B, C to Journey and save
  6. Edit Journey and reorder Places to B, A, C and save

New order of Places in Journey is saved correctly in MongoDB (verified with Mongo Compass).

REST API and Admin page uses the new order but GraphQL fail to respect order and return Places always in alphabetical order.

Order of the Places in Journey is business critical feature.

Basically, you can get it to work by adding some logic when updating. Remove all relations first and then add all relations again (instead of just updating them). You can do that in a beforeUpdate lifecycle function. I’m guessing there is a reason the default relation update-logic works the way it does today (maybe performance?)

As to ordering in graphql responses, there is an issue with relations in the schema generator. In type-definitions.js the “targetIds” are used in a filter sent in to the data loader and the ordering is not preserved in the result (in the buildAssocResolvers function). However, you can re-order the response from the data loader by using the original targetIds-array. This requires changing the graphql-plugin code (I think it could be done as an extension though)

@teemu

The problem occurs there due to how we populate relations in the v3 codebase:

This is something we are planning to adjust in the v4, will have more information when we have a public version of our v4 RFC (v4 development will start after StrapiConf)

The manual ordering of relations was a decision driver for my current project. Right now I’m testing the workaround from @jorisw . So yeah, I would really recommend to disable the UI function otherwise other people might be irritated.

@Anders_Hammar @DMehaffy Thank for your replies and sorry for the delay with thanking. We pushed our own bug ticket forward as far as we could and we are only now starting to work with this problem again.

Hi @lilaL

Did you find a solution for this with current Strapi implementation?

Hey @teemu
I’ve used the workaround suggested above: Save relation items order (Issue #2166) - #4 by jorisw
It’s still not the perfect solution but it works!