Auto-sort collections by a product property, with Mechanic.

Mechanic is a development and ecommerce automation platform for Shopify. :)

Auto-sort collections by a product property

This task re-sorts your collections by the product property, product metafield, or variant property that you choose. Use the "Product property" or "First variant property" options to control what attribute the task looks up. For example, using publishedAt in the "Product property" field will result in sorting by the date and time the product was published, while using sku in the "First variant property" field will result in sorting by the sku of the first variant of each product in the collection. Alternatively, enter a product metafield as "namespace.key" (e.g. store.priority), and the task will attempt to sort by the value of that metafield.

Runs Occurs when a user manually triggers the task, Occurs whenever user/collection_sort/process is triggered, and Occurs whenever user/collection_sort/complete is triggered. Configuration includes product property, product metafield, first variant property, only sort these collections, reverse sort, sort naturally, run hourly, and run daily.

15-day free trial – unlimited tasks

Documentation

This task re-sorts your collections by the product property, product metafield, or variant property that you choose. Use the "Product property" or "First variant property" options to control what attribute the task looks up. For example, using publishedAt in the "Product property" field will result in sorting by the date and time the product was published, while using sku in the "First variant property" field will result in sorting by the sku of the first variant of each product in the collection. Alternatively, enter a product metafield as "namespace.key" (e.g. store.priority), and the task will attempt to sort by the value of that metafield.

Run this task manually to re-sort your collections on demand, or choose to run it hourly or nightly. This task will scan all collections in the shop on each run, unless you configure it to only sort certain collections using each collection's title, handle, or ID. Optionally, choose the "Reverse sort" option to have the results reversed, mainly useful for sorting by descending numeric values (e.g. inventoryTotal). If the property/metafield value contains numeric strings, consider using the "Sort naturally" task option.

Important:

  • You may only choose one sorting method.
  • The collections processed by this task must be configured for manual sorting, otherwise they will be ignored.
  • The property you choose should be a DateTime, Int, or String field on the GraphQL Product or GraphQL ProductVariant resource, otherwise the task will generate an error during execution. For help migrating from prior versions of this task, Shopify has documented the mappings for REST product properties and REST product variant properties to GraphQL. (e.g. REST: published_at => GraphQL: publishedAt)
  • Products with values for a property/metafield will always be placed before products with no values. This rule applies even when the sort order is reversed.
  • If a metafield is configured that does not exist, the task will just report that no moves are needed. No error will be thrown.

Developer details

Mechanic is designed to benefit everybody: merchants, customers, developers, agencies, Shopifolks, everybody.

That’s why we make it easy to configure automation without code, why we make it easy to tweak the underlying code once tasks are installed, and why we publish it all here for everyone to learn from.

(By the way, have you seen our documentation? Have you joined the Slack community?)

Open source
View on GitHub to contribute to this task
Subscriptions
mechanic/user/trigger
user/collection_sort/process
user/collection_sort/complete
{% if options.run_hourly__boolean %}
  mechanic/scheduler/hourly
{% elsif options.run_daily__boolean %}
  mechanic/scheduler/daily
{% endif %}
Tasks use subscriptions to sign up for specific kinds of events. Learn more
Options
product property, product metafield, first variant property, only sort these collections (array), reverse sort (boolean), sort naturally (boolean), run hourly (boolean), run daily (boolean)
Code
{% assign product_property = options.product_property %}
{% assign product_metafield = options.product_metafield %}
{% assign first_variant_property = options.first_variant_property %}
{% assign only_sort_these_collections = options.only_sort_these_collections__array %}
{% assign reverse_sort = options.reverse_sort__boolean %}
{% assign sort_naturally = options.sort_naturally__boolean %}

{% comment %}
  -- build the query fragment for the configured property
{% endcomment %}

{% assign property_fields_count = 0 %}

{% if product_property != blank %}
  {% assign property_fields_count = property_fields_count | plus: 1 %}
{% endif %}

{% if product_metafield != blank %}
  {% assign property_fields_count = property_fields_count | plus: 1 %}
{% endif %}

{% if first_variant_property != blank %}
  {% assign property_fields_count = property_fields_count | plus: 1 %}
{% endif %}

{% if property_fields_count != 1 %}
  {% error "Configure one (and only one) of these options: 'Product property', 'Product metafield', or 'First variant property'" %}
  {% break %}
{% endif %}

{% assign query_fragment = nil %}

{% if product_property != blank %}
  {% assign query_fragment = product_property %}

{% elsif product_metafield != blank %}
  {%- capture query_fragment -%}
    metafield(key: {{ product_metafield | json }}) {
      value
    }
  {%- endcapture -%}

{% elsif first_variant_property != blank %}
  {%- capture query_fragment -%}
    variants(first: 1) {
      nodes {
        {{ first_variant_property }}
      }
    }
  {%- endcapture -%}
{% endif %}

{% if event.topic == "mechanic/user/trigger" or event.topic contains "mechanic/scheduler/" %}
  {% comment %}
    -- get IDs for all manually sorted collections, optionally restricted to specific collections by ID or title
  {% endcomment %}

  {% assign collection_ids_to_sort = array %}
  {% assign cursor = nil %}

  {% for n in (1..50) %}
    {% capture query %}
      query {
        collections(
          first: 250
          after: {{ cursor | json }}
        ) {
          pageInfo {
            hasNextPage
            endCursor
          }
          nodes {
            id
            legacyResourceId
            title
            handle
            sortOrder
          }
        }
      }
    {% endcapture %}

    {% assign result = query | shopify %}

    {% if event.preview %}
      {% capture result_json %}
        {
          "data": {
            "collections": {
              "nodes": [
                {
                  "id": "gid://shopify/Collection/1234567890",
                  "legacyResourceId": "1234567890",
                  "sortOrder": "MANUAL"
                }
              ]
            }
          }
        }
      {% endcapture %}

      {% assign result = result_json | parse_json %}
    {% endif %}

    {% comment %}
      -- loop through collections and filter by ID, title, or handle as configured
    {% endcomment %}

    {% for collection in result.data.collections.nodes %}
      {% if only_sort_these_collections != blank %}
        {% unless only_sort_these_collections contains collection.legacyResourceId
          or only_sort_these_collections contains collection.title
          or only_sort_these_collections contains collection.handle
          or event.preview
        %}
          {% continue %}
        {% endunless %}
      {% endif %}

      {% if collection.sortOrder != "MANUAL" %}
        {% log %}
          {{ collection.title | json | append: " is not configured for manual sorting; skipping." | json }}
        {% endlog %}
        {% continue %}
      {% endif %}

      {% assign collection_ids_to_sort = collection_ids_to_sort | push: collection.id %}
    {% endfor %}

    {% if result.data.collections.pageInfo.hasNextPage %}
      {% assign cursor = result.data.collections.pageInfo.endCursor %}
    {% else %}
      {% break %}
    {% endif %}
  {% endfor %}

  {% if collection_ids_to_sort == blank %}
    {% log "None of the configured collections were found or set for manual sorting." %}
    {% break %}
  {% endif %}

  {% comment %}
    -- save the qualified collection IDs to the cache for lookup in later child events
    -- NOTE: using event ID instead of task ID in case one instance of this task runs concurrent with another
  {% endcomment %}

  {% assign cache_key = event.id | prepend: "collection_ids_to_sort_" %}
  {% action "cache", "set", cache_key, collection_ids_to_sort %}

  {% log
    message: "Begin processing collections loop using sequential child events.",
    total_collections_to_sort: collection_ids_to_sort.size
  %}

  {% action "event" %}
    {
      "topic": "user/collection_sort/process",
      "task_id": {{ task.id | json }},
      "data": {
        "cache_key": {{ cache_key | json }},
        "cache_index": 0
      }
    }
  {% endaction %}

{% elsif event.topic == "user/collection_sort/process" %}
  {% assign cache_key = event.data.cache_key %}
  {% assign cache_index = event.data.cache_index %}
  {% assign collection_ids_to_sort = cache[cache_key] %}
  {% assign collection_id = collection_ids_to_sort[cache_index] %}

  {% assign moves = array %}
  {% assign product_ids_and_positions = hash %}
  {% assign product_ids_and_values = array %}

  {% assign cursor = nil %}
  {% assign products = array %}

  {% for n in (1..100) %}
    {% capture query %}
      query {
        collection(id: {{ collection_id | json }}) {
          id
          title
          products(
            first: 250
            after: {{ cursor | json }}
            sortKey: COLLECTION_DEFAULT
          ) {
            pageInfo {
              hasNextPage
              endCursor
            }
            nodes {
              id
              {{ query_fragment }}
            }
          }
        }
      }
    {% endcapture %}

    {% assign result = query | shopify %}

    {% if event.preview %}
      {% capture result_json %}
        {
          "data": {
            "collection": {
              "id": "gid://shopify/Collection/1234567890",
              "title": "Widgets",
              "products": {
                "nodes": [
                  {
                    "id": "gid://shopify/Product/1234567890"
                  },
                  {
                    "id": "gid://shopify/Product/2345678901"
                  }
                ]
              }
            }
          }
        }
      {% endcapture %}

      {% assign result = result_json | parse_json %}
    {% endif %}

    {% assign collection = result.data.collection | except: "products" %}
    {% assign products = products | concat: result.data.collection.products.nodes %}

    {% if result.data.collection.products.pageInfo.hasNextPage %}
      {% assign cursor = result.data.collection.products.pageInfo.endCursor %}
    {% else %}
      {% break %}
    {% endif %}
  {% endfor %}

  {% log collection_products: products %}

  {% for product in products %}
    {% assign product_ids_and_positions[product.id] = forloop.index0 %}

    {% assign product_id_and_value = hash %}
    {% assign product_id_and_value["id"] = product.id %}

    {% if product_property != blank %}
      {% assign value = product[product_property] %}

    {% elsif product_metafield != blank %}
      {% assign value = product.metafield.value %}

    {% elsif first_variant_property != blank %}
      {% assign value = product.variants.nodes.first[first_variant_property] %}
    {% endif %}

    {% comment %}
      -- make sure this is always a serializable/sortable object, defaulting to nil in the case of an empty string
    {% endcomment %}

    {% assign product_id_and_value["value"] = value | json | parse_json | default: nil %}
    {% assign product_ids_and_values[product_ids_and_values.size] = product_id_and_value %}
  {% endfor %}

  {% assign current_sort_product_ids = product_ids_and_values | map: "id" %}

  {% comment %}
    -- use sort_natural filter so the sort is case-insensitive
    -- use sort_naturally when option chosen to sort numeric strings as humans might expect
    -- exclude null values from the sort since sort_naturally leaves them in front
  {% endcomment %}

  {% if sort_naturally %}
    {% assign sorted_product_values
      = product_ids_and_values
      | where: "value"
      | sort_naturally: "value"
    %}

  {% else %}
    {% assign sorted_product_values
      = product_ids_and_values
      | where: "value"
      | sort_natural: "value"
    %}
  {% endif %}

  {% log sorted_product_values: sorted_product_values %}

  {% comment %}
    -- concatenate nil values to end of sorted IDs regardless of whether the sort is reversed or not
  {% endcomment %}

  {% if reverse_sort %}
    {% assign sorted_product_ids
      = sorted_product_values
      | map: "id"
      | reverse
      | concat: current_sort_product_ids
      | uniq
    %}

  {% else %}
    {% assign sorted_product_ids
      = sorted_product_values
      | map: "id"
      | concat: current_sort_product_ids
      | uniq
    %}
  {% endif %}

  {% log sorted_product_ids: sorted_product_ids %}

  {% comment %}
    -- determine the moves necessary to place products in their sorted positions
  {% endcomment %}

  {% for sorted_product_id in sorted_product_ids %}
    {% if forloop.index0 != product_ids_and_positions[sorted_product_id] %}
      {% assign move = hash %}
      {% assign move["id"] = sorted_product_id %}
      {% assign move["newPosition"] = "" | append: forloop.index0 %}
      {% assign moves[moves.size] = move %}
    {% endif %}
  {% endfor %}

  {% comment %}
    -- add preview here so collectionReorderProducts is reached, and the proper scopes are requested
  {% endcomment %}

  {% if event.preview %}
    {% capture moves_json %}
      [
        {
          "id": "gid://shopify/Product/1234567890",
          "newPosition": "1"
        },
        {
          "id": "gid://shopify/Product/2345678901",
          "newPosition": "0"
        }
      ]
    {% endcapture %}

    {% assign moves = moves_json | parse_json %}
  {% endif %}

  {% if moves == blank %}
    {% log
      message: "No moves necessary for this collection, everything is already in its appropriate sort order.",
      collection: collection.title
    %}

  {% else %}
    {% log
      message: "Collection requires sorting.",
      collection: collection.title
    %}

    {% comment %}
      -- using reverse filter below due to a bug in the collectionReorderProducts mutation
      -- this filter will NOT affect the sort order determined above
    {% endcomment %}

    {% assign moves_in_groups = moves | reverse | in_groups_of: 250, fill_with: false %}

    {% for move_group in moves_in_groups %}
      {% action "shopify" %}
        mutation {
          collectionReorderProducts(
            id: {{ collection.id | json }}
            moves: {{ move_group | graphql_arguments }}
          ) {
            userErrors {
              field
              message
            }
          }
        }
      {% endaction %}
    {% endfor %}
  {% endif %}

  {% assign new_cache_index = cache_index | plus: 1 %}

  {% log
    collections_seen: new_cache_index,
    total_collections_to_sort: collection_ids_to_sort.size
  %}

  {% comment %}
    -- process next collection in the cache if there is one
  {% endcomment %}

  {% if new_cache_index < collection_ids_to_sort.size %}
    {% action "event" %}
      {
        "topic": "user/collection_sort/process",
        "task_id": {{ task.id | json }},
        "data": {
          "cache_key": {{ cache_key | json }},
          "cache_index": {{ new_cache_index }}
        }
      }
    {% endaction %}

  {% else %}
    {% comment %}
      -- use a distinct user event to indicate the entire task run is complete, so it can be filtered in the event log
    {% endcomment %}

    {% action "event" %}
      {
        "topic": "user/collection_sort/complete",
        "task_id": {{ task.id | json }},
        "data": {
          "cache_key": {{ cache_key | json }},
          "cache_index": {{ new_cache_index }}
        }
      }
    {% endaction %}
  {% endif %}

{% elsif event.topic == "user/collection_sort/complete" %}
  {% assign cache_key = event.data.cache_key %}
  {% assign cache_index = event.data.cache_index %}

  {% log
    message: "Collection sorting complete. Deleting cached collection IDs.",
    collections_seen: cache_index
  %}

  {% action "cache", "del", cache_key %}
{% endif %}
Task code is written in Mechanic Liquid, an extension of open-source Liquid enhanced for automation. Learn more
Defaults
Product property
publishedAt