From 6bd3b78c5132f3362bab18fa2ad6c4c25dd5236c Mon Sep 17 00:00:00 2001 From: Brad Hover Date: Sat, 31 Aug 2024 22:46:49 -0700 Subject: [PATCH] first batch of REST to GraphQL conversions for products and variants --- docs/README.md | 3 +- .../README.md | 3 +- .../script.liquid | 171 ++++++++--- .../README.md | 10 +- .../script.liquid | 221 ++++++++++---- .../README.md | 6 +- .../script.liquid | 109 ++++++- .../README.md | 4 +- .../script.liquid | 273 +++++++++++++----- tasks/auto-tag-orders-using-product-tags.json | 9 +- ...products-that-have-a-compare-at-price.json | 9 +- ...generate-a-simple-product-catalog-pdf.json | 8 +- ...k-products-to-the-end-of-a-collection.json | 6 +- 13 files changed, 609 insertions(+), 223 deletions(-) diff --git a/docs/README.md b/docs/README.md index 1a8c27f0..ddbbade1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -844,7 +844,6 @@ This directory is built automatically. Each task's documentation is generated fr * [Email vendors when their products are ordered](./email-vendors-when-their-products-are-ordered) * [Email your customers after a quiet period of no orders](./email-your-customers-after-a-quiet-period-of-no-orders) * [Forward incoming email to another address](./forward-incoming-email-to-another-address) -* [Generate a simple product catalog PDF](./generate-a-simple-product-catalog-pdf) * [Get email alerts for FBA failures](./get-email-alerts-for-fba-failures) * [Get email alerts for out of stock products](./get-email-alerts-for-out-of-stock-products) * [Notify a team when a tagged product is ordered](./notify-a-team-when-a-tagged-product-is-ordered) @@ -1402,6 +1401,7 @@ This directory is built automatically. Each task's documentation is generated fr * [Find duplicate SKUs](./find-duplicate-skus) * [Generate a discount code when a certain product is purchased](./generate-a-discount-code-when-a-certain-product-is-purchased) * [Generate a product discount when a voucher product is purchased](./generate-a-product-discount-when-a-voucher-product-is-purchased) +* [Generate a simple product catalog PDF](./generate-a-simple-product-catalog-pdf) * [Hide out-of-stock products](./hide-out-of-stock-products) * [Keep inventory levels in sync within products](./keep-inventory-levels-in-sync-within-products) * [Maintain a collection of new products](./maintain-a-collection-of-new-products) @@ -1556,6 +1556,7 @@ This directory is built automatically. Each task's documentation is generated fr ### Sale * [Advanced: Scheduled Price Changes](./advanced-scheduled-price-changes) +* [Auto-tag products that have a "compare at" price](./auto-tag-products-that-have-a-compare-at-price) ### Sales Channel diff --git a/docs/auto-tag-orders-using-product-tags/README.md b/docs/auto-tag-orders-using-product-tags/README.md index d0cc0710..798bbe9f 100644 --- a/docs/auto-tag-orders-using-product-tags/README.md +++ b/docs/auto-tag-orders-using-product-tags/README.md @@ -13,7 +13,7 @@ Use this task to tag incoming orders with all the product tags in the order. Opt ```json { "only_copy_these_tags__array": null, - "only_copy_tags_having_this_prefix": null + "only_copy_tags_having_one_of_these_prefixes__array": null } ``` @@ -25,6 +25,7 @@ Use this task to tag incoming orders with all the product tags in the order. Opt shopify/orders/create mechanic/user/trigger mechanic/shopify/bulk_operation +mechanic/user/order ``` [Learn about event subscriptions in Mechanic](https://learn.mechanic.dev/core/tasks/subscriptions) diff --git a/docs/auto-tag-orders-using-product-tags/script.liquid b/docs/auto-tag-orders-using-product-tags/script.liquid index 6b359c49..dbb094e5 100644 --- a/docs/auto-tag-orders-using-product-tags/script.liquid +++ b/docs/auto-tag-orders-using-product-tags/script.liquid @@ -1,35 +1,95 @@ +{% assign only_copy_these_tags = options.only_copy_these_tags__array %} +{% assign only_copy_tags_having_one_of_these_prefixes = options.only_copy_tags_having_one_of_these_prefixes__array %} + +{% comment %} + -- check configured tags to make sure no inadvertent spaces are present +{% endcomment %} + +{% for tag in only_copy_these_tags %} + {% assign tag_check = tag | strip %} + + {% if tag_check == "" %} + {% error "'Only copy these tags' contains an empty entry. Please correct to continue." %} + {% endif %} +{% endfor %} + +{% for tag in only_copy_tags_having_one_of_these_prefixes %} + {% assign tag_check = tag | strip %} + + {% if tag_check == "" %} + {% error "'Only copy tags having one of these prefixes' contains an empty entry. Please correct to continue." %} + {% endif %} +{% endfor %} + {% assign order_ids_tags_and_product_tags = array %} -{% if event.topic == "shopify/orders/create" %} +{% if event.topic == "shopify/orders/create" or event.topic == "mechanic/user/order" %} + {% comment %} + -- query the order, line items, products, and tags + {% endcomment %} + + {% capture query %} + query { + order(id: {{ order.admin_graphql_api_id | json }}) { + id + tags + lineItems(first: 250) { + nodes { + product { + tags + } + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + {% if event.preview %} - {% capture order_json %} + {% capture result_json %} { - "admin_graphql_api_id": "gid://shopify/Order/12345", - "line_items": [ - { - "product": { - "tags": {{ options.only_copy_these_tags__array | join: ", " | default: "preorder" | json }} - } - }, - { - "product": { - "tags": {{ options.only_copy_tags_having_this_prefix | default: "important" | append: "-order" | json }} + "data": { + "order": { + "id": "gid://shopify/Order/1234567890", + "lineItems": { + "nodes": [ + { + "product": { + "tags": [ + "preview-tag", + {{ only_copy_these_tags.first | json }}, + {{ only_copy_tags_having_one_of_these_prefixes.first | json }} + ] + } + } + ] } } - ] + } } {% endcapture %} - {% assign order = order_json | parse_json %} + {% assign result = result_json | parse_json %} {% endif %} - {% assign order_product_tags = order.line_items | map: "product" | map: "tags" | join: ", " | split: ", " %} + {% assign order = result.data.order %} + + {% comment %} + -- save order ID and tags as the only item in the array for processing + {% endcomment %} + {% assign item = array %} - {% assign item[0] = order.admin_graphql_api_id %} - {% assign item[1] = order.tags | split: ", " %} - {% assign item[2] = order_product_tags %} + {% assign item[0] = order.id %} + {% assign item[1] = order.tags %} + {% assign item[2] = order.lineItems.nodes | map: "product" | map: "tags" | uniq %} {% assign order_ids_tags_and_product_tags[0] = item %} + {% elsif event.topic == "mechanic/user/trigger" %} + {% comment %} + -- use bulk op to get all orders in the shop + {% endcomment %} + {% capture bulk_operation_query %} query { orders { @@ -42,7 +102,6 @@ edges { node { __typename - id product { tags } @@ -71,12 +130,12 @@ } } {% endaction %} + {% elsif event.topic == "mechanic/shopify/bulk_operation" %} {% if event.preview %} {% capture objects_jsonl %} {"__typename":"Order","id":"gid:\/\/shopify\/Order\/1234567890","tags":[]} - {"__typename":"LineItem","__parentId":"gid:\/\/shopify\/Order\/1234567890","id":"gid:\/\/shopify\/LineItem\/1234567890","product":{"tags":{{ options.only_copy_these_tags__array | join: "," | default: "preorder" | split: "," | json }}},"__parentId":"gid:\/\/shopify\/Order\/1234567890"} - {"__typename":"LineItem","__parentId":"gid:\/\/shopify\/Order\/1234567890","id":"gid:\/\/shopify\/LineItem\/2345678901","product":{"tags":[{{ options.only_copy_tags_having_this_prefix | default: "important" | append: "-order" | json }}]},"__parentId":"gid:\/\/shopify\/Order\/1234567890"} + {"__typename":"LineItem","__parentId":"gid:\/\/shopify\/Order\/1234567890","product":{"tags":["preview-tag",{{ only_copy_these_tags.first | json }},{{ only_copy_tags_having_one_of_these_prefixes.first | json }}]}} {% endcapture %} {% assign bulkOperation = hash %} @@ -84,14 +143,20 @@ {% endif %} {% assign orders = bulkOperation.objects | where: "__typename", "Order" %} - {% assign lineItems = bulkOperation.objects | where: "__typename", "LineItem" %} + {% assign line_items = bulkOperation.objects | where: "__typename", "LineItem" %} + + {% comment %} + -- save order IDs and tags in array for processing + {% endcomment %} {% for order in orders %} - {% assign order_products = lineItems | where: "__parentId", order.id | map: "product" | compact %} - {% assign order_product_tags = array %} - {% for product in order_products %} - {% assign order_product_tags = order_product_tags | concat: product.tags | uniq %} - {% endfor %} + {% assign order_product_tags + = line_items + | where: "__parentId", order.id + | map: "product" + | map: "tags" + | uniq + %} {% assign item = array %} {% assign item[0] = order.id %} @@ -101,6 +166,10 @@ {% endfor %} {% endif %} +{% comment %} + -- process items to see which tags to add to each order +{% endcomment %} + {% for item in order_ids_tags_and_product_tags %} {% assign order_id = item[0] %} {% assign order_tags = item[1] %} @@ -108,37 +177,43 @@ {% assign tags_to_add = array %} - {% for tag in order_product_tags %} - {% if order_tags contains tag %} + {% for product_tag in order_product_tags %} + {% if order_tags contains product_tag %} {% continue %} {% endif %} - {% if options.only_copy_these_tags__array != blank %} - {% if options.only_copy_these_tags__array contains nil %} - {% error "'Only copy these tags' must not contain any blank items. If you don't want to use this option, remove its remaining items." %} - {% endif %} - - {% unless options.only_copy_these_tags__array contains tag %} - {% continue %} - {% endunless %} + {% if only_copy_these_tags == blank and only_copy_tags_having_one_of_these_prefixes == blank %} + {% assign tags_to_add = tags_to_add | push: product_tag %} + {% continue %} {% endif %} - {% if options.only_copy_tags_having_this_prefix != blank %} - {% assign prefix_length = options.only_copy_tags_having_this_prefix.size %} - {% assign tag_substr = tag | slice: 0, prefix_length %} - {% if tag_substr != options.only_copy_tags_having_this_prefix %} - {% continue %} + {% for tag in only_copy_these_tags %} + {% if tag == product_tag %} + {% assign tags_to_add = tags_to_add | push: product_tag %} {% endif %} - {% endif %} + {% endfor %} + + {% comment %} + -- make sure the prefix matches the beginning of the tag + {% endcomment %} - {% assign tags_to_add[tags_to_add.size] = tag %} + {% for tag in only_copy_tags_having_one_of_these_prefixes %} + {% assign prefix_length = tag.size %} + {% assign product_tag_substr = product_tag | slice: 0, prefix_length %} + + {% if tag == product_tag_substr %} + {% assign tags_to_add = tags_to_add | push: product_tag %} + {% endif %} + {% endfor %} {% endfor %} - {% if event.preview and tags_to_add == empty %} - {% error "It looks like you have conflicting options configured. Please double-check your options, and try again. :)" %} - {% endif %} + {% comment %} + -- remove duplicate tags before adding to order + {% endcomment %} + + {% assign tags_to_add = tags_to_add | compact | uniq %} - {% if tags_to_add != empty %} + {% if tags_to_add != blank %} {% action "shopify" %} mutation { tagsAdd( diff --git a/docs/auto-tag-products-that-have-a-compare-at-price/README.md b/docs/auto-tag-products-that-have-a-compare-at-price/README.md index d3e9af4d..8f2d4ce4 100644 --- a/docs/auto-tag-products-that-have-a-compare-at-price/README.md +++ b/docs/auto-tag-products-that-have-a-compare-at-price/README.md @@ -1,8 +1,8 @@ # Auto-tag products that have a "compare at" price -Tags: Auto-Tag, Compare at, Products +Tags: Auto-Tag, Compare at, Products, Sale -This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that aren't on sale), and Mechanic will take care of applying and removing tags as appropriate. If you're using Shopify discounts, this can allow you to use automatic sale collections – based on these tags – to control eligibility for your discounts. +This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that _aren't_ on sale), and Mechanic will take care of applying and removing tags as appropriate. * View in the task library: [tasks.mechanic.dev/auto-tag-products-that-have-a-compare-at-price](https://tasks.mechanic.dev/auto-tag-products-that-have-a-compare-at-price) * Task JSON, for direct import: [task.json](../../tasks/auto-tag-products-that-have-a-compare-at-price.json) @@ -12,7 +12,7 @@ This task will keep your sale tags in sync, without any manual work. Configure t ```json { - "tag_for_sale_products": "on-sale", + "tag_for_sale_products__required": "on-sale", "tag_for_all_other_products": "not-on-sale", "sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean": true } @@ -32,10 +32,8 @@ mechanic/user/trigger ## Documentation -This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that aren't on sale), and Mechanic will take care of applying and removing tags as appropriate. If you're using Shopify discounts, this can allow you to use automatic sale collections – based on these tags – to control eligibility for your discounts. +This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that _aren't_ on sale), and Mechanic will take care of applying and removing tags as appropriate. -This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that _aren't_ on sale), and Mechanic will take care of applying and removing tags as appropriate. - Run this task manually to update your entire product catalog at once. ## Installing this task diff --git a/docs/auto-tag-products-that-have-a-compare-at-price/script.liquid b/docs/auto-tag-products-that-have-a-compare-at-price/script.liquid index b9564f5a..b34e6934 100644 --- a/docs/auto-tag-products-that-have-a-compare-at-price/script.liquid +++ b/docs/auto-tag-products-that-have-a-compare-at-price/script.liquid @@ -1,83 +1,194 @@ -{% if options.tag_for_sale_products == blank and options.tag_for_all_other_products == blank %} - {% error "Please fill in at least one of the two tag options." %} +{% assign tag_for_sale_products = options.tag_for_sale_products__required %} +{% assign tag_for_all_other_products = options.tag_for_all_other_products %} +{% assign must_have_price_lower_than_the_compare_at_price = options.sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean %} + +{% comment %} + -- for product create/update, filter the products query with the product ID that caused the event +{% endcomment %} + +{% if event.topic == "shopify/products/update" %} + {% assign search_query = product.id | prepend: "id:" %} {% endif %} -{% if event.preview %} - {% capture products_json %} - [ +{% comment %} + -- query product(s) in the shop; variants will be queried separately as needed to support up to 2K variants +{% endcomment %} + +{% assign cursor = nil %} +{% assign products = array %} + +{% for n in (1..100) %} + {% capture query %} + query { + products( + first: 250 + after: {{ cursor | json }} + query: {{ search_query | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + tags + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} { - "admin_graphql_api_id": "gid://shopify/Product/1234567890", - "tags": "", - "variants": [ - { - "price": "19.99", - "compare_at_price": "24.99" + "data": { + "products": { + "nodes": [ + { + "id": "gid://shopify/Product/1234567890" + } + ] } - ] + } } - ] - {% endcapture %} + {% endcapture %} - {% assign products = products_json | parse_json %} -{% elsif event.topic contains "shopify/products/" %} - {% assign products = array %} - {% assign products[0] = product %} -{% elsif event.topic == "mechanic/user/trigger" %} - {% assign products = shop.products %} -{% endif %} + {% assign result = result_json | parse_json %} + {% endif %} + + {% assign products = products | concat: result.data.products.nodes %} + + {% if result.data.products.pageInfo.hasNextPage %} + {% assign cursor = result.data.products.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} +{% endfor %} + +{% comment %} + -- process each product by querying as many variants as needed to determine which tag applies +{% endcomment %} {% for product in products %} - {% assign has_compare_at_price = false %} - {% assign has_sale_price = false %} + {% assign product_qualifies_as_sale = nil %} + {% assign cursor = nil %} + + {% for n in (1..8) %} + {% capture query %} + query { + product(id: {{ product.id | json }}) { + variants( + first: 250 + after: {{ cursor | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + price + compareAtPrice + } + } + } + } + {% endcapture %} - {% for variant in product.variants %} - {% if variant.compare_at_price == blank %} - {% continue %} + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "product": { + "variants": { + "nodes": [ + { + "price": "19.99", + "compareAtPrice": "24.99" + } + ] + } + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} {% endif %} - {% assign has_compare_at_price = true %} + {% assign variants = result.data.product.variants.nodes %} + + {% comment %} + -- check batch of variants to see if a compare at price exists, and optionally if it is greater than the price + {% endcomment %} - {% if options.sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean %} - {% assign price = variant.price | times: 1 %} - {% assign compare_at_price = variant.compare_at_price | times: 1 %} - {% if price < compare_at_price %} - {% assign has_sale_price = true %} + {% for variant in variants %} + {% if variant.compareAtPrice == blank %} + {% continue %} {% endif %} - {% endif %} - {% endfor %} - {% assign product_tags = product.tags | split: ", " %} + {% if must_have_price_lower_than_the_compare_at_price %} + {% assign price = variant.price | times: 1 %} + {% assign compare_at_price = variant.compareAtPrice | times: 1 %} - {% assign tag_to_add = nil %} - {% assign tag_to_remove = nil %} + {% if price < compare_at_price %} + {% assign product_qualifies_as_sale = true %} + {% break %} + {% endif %} - {% assign product_qualifies_as_sale = false %} - {% if has_compare_at_price %} - {% if options.sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean %} - {% if has_sale_price %} + {% else %} {% assign product_qualifies_as_sale = true %} + {% break %} {% endif %} + {% endfor %} + + {% comment %} + -- only query for more variants if the product has not yet qualified as "on sale" AND if there are more variants + {% endcomment %} + + {% if product_qualifies_as_sale %} + {% break %} + {% elsif result.data.product.variants.pageInfo.hasNextPage %} + {% assign cursor = result.data.product.variants.pageInfo.endCursor %} {% else %} - {% assign product_qualifies_as_sale = true %} + {% break %} {% endif %} - {% endif %} + {% endfor %} + + {% comment %} + -- adjust tags on product as needed + {% endcomment %} + + {% assign tag_to_add = nil %} + {% assign tag_to_remove = nil %} {% if product_qualifies_as_sale %} - {% unless product_tags contains options.tag_for_sale_products %} - {% assign tag_to_add = options.tag_for_sale_products %} - {% endunless %} + {% if tag_for_sale_products != blank %} + {% unless product.tags contains tag_for_sale_products %} + {% assign tag_to_add = tag_for_sale_products %} + {% endunless %} + {% endif %} - {% if product_tags contains options.tag_for_all_other_products %} - {% assign tag_to_remove = options.tag_for_all_other_products %} + {% if tag_for_all_other_products != blank %} + {% if product.tags contains tag_for_all_other_products %} + {% assign tag_to_remove = tag_for_all_other_products %} + {% endif %} {% endif %} + {% else %} - {% if product_tags contains options.tag_for_sale_products %} - {% assign tag_to_remove = options.tag_for_sale_products %} + {% if tag_for_sale_products != blank %} + {% if product.tags contains tag_for_sale_products %} + {% assign tag_to_remove = tag_for_sale_products %} + {% endif %} {% endif %} - {% unless product_tags contains options.tag_for_all_other_products %} - {% assign tag_to_add = options.tag_for_all_other_products %} - {% endunless %} + {% if tag_for_all_other_products != blank %} + {% unless product.tags contains tag_for_all_other_products %} + {% assign tag_to_add = tag_for_all_other_products %} + {% endunless %} + {% endif %} {% endif %} {% if tag_to_add or tag_to_remove %} @@ -85,7 +196,7 @@ mutation { {% if tag_to_add %} tagsAdd( - id: {{ product.admin_graphql_api_id | json }} + id: {{ product.id | json }} tags: {{ tag_to_add | json }} ) { userErrors { @@ -97,7 +208,7 @@ {% if tag_to_remove %} tagsRemove( - id: {{ product.admin_graphql_api_id | json }} + id: {{ product.id | json }} tags: {{ tag_to_remove | json }} ) { userErrors { diff --git a/docs/generate-a-simple-product-catalog-pdf/README.md b/docs/generate-a-simple-product-catalog-pdf/README.md index e2cb4274..18513814 100644 --- a/docs/generate-a-simple-product-catalog-pdf/README.md +++ b/docs/generate-a-simple-product-catalog-pdf/README.md @@ -1,6 +1,6 @@ # Generate a simple product catalog PDF -Tags: Catalog, Email, Files, PDF +Tags: Catalog, Files, PDF, Products This task is a simple demonstration of pulling product titles and images into a PDF, resulting in a simple catalog of the products available in your store. Use it as a starting point for building something more complex. @@ -18,8 +18,8 @@ mechanic/user/trigger ## Documentation -This task is a simple demonstration of pulling product titles and images into a PDF, resulting in a simple catalog of the products available in your store. Use it as a starting point for building something more complex. - +This task is a simple demonstration of pulling product titles and images into a PDF, resulting in a simple catalog of the products available in your store. Use it as a starting point for building something more complex. + Run this task manually to generate the PDF. ## Installing this task diff --git a/docs/generate-a-simple-product-catalog-pdf/script.liquid b/docs/generate-a-simple-product-catalog-pdf/script.liquid index 9041220e..19c3a653 100644 --- a/docs/generate-a-simple-product-catalog-pdf/script.liquid +++ b/docs/generate-a-simple-product-catalog-pdf/script.liquid @@ -1,20 +1,105 @@ +{% comment %} + -- get all of the products in the shop (up to 25K), and their feature images if they exist +{% endcomment %} + +{% assign cursor = nil %} +{% assign products_output = array %} + +{% for n in (1..100) %} + {% capture query %} + query { + products( + first: 250 + after: {{ cursor | json }} + sortKey: TITLE + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + title + featuredImage { + url( + transform: { + maxWidth: 300 + maxHeight: 300 + crop: CENTER + } + ) + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "products": { + "nodes": [ + { + "id": "gid://shopify/Product/1234567890", + "title": "Widget", + "featuredImage": { + "url": "https://cdn.shopify.com/s/files/1/1234/5678/1234/products/widget_300x300_crop_center.jpg?v=1234567890" + } + } + ] + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} + + {% comment %} + -- save the output for this product in an array + {% endcomment %} + + {% for product in result.data.products.nodes %} + {% capture product_html %} +
  • + {{ product.title }} +
    + {% if product.featuredImage %} + + {% else %} + (no image) + {% endif %} +
  • + {% endcapture %} + + {% assign products_output = products_output | push: product_html %} + {% endfor %} + + {% if result.data.products.pageInfo.hasNextPage %} + {% assign cursor = result.data.products.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} +{% endfor %} + +{% comment %} + -- capture the HTML used by the PDF generator +{% endcomment %} + {% capture html %}

    Product catalog

    {% endcapture %} +{% comment %} + -- generate the PDF catalog, which will appear as a file download in the task run log +{% endcomment %} + {% action "files" %} { "catalog.pdf": { diff --git a/docs/move-out-of-stock-products-to-the-end-of-a-collection/README.md b/docs/move-out-of-stock-products-to-the-end-of-a-collection/README.md index 95b9423b..c81be3a5 100644 --- a/docs/move-out-of-stock-products-to-the-end-of-a-collection/README.md +++ b/docs/move-out-of-stock-products-to-the-end-of-a-collection/README.md @@ -13,8 +13,8 @@ This task re-sorts your collections, beginning with the sort order of your choic ```json { "base_sort_order__required": "ALPHA_ASC", - "collection_titles_or_ids_to_include__array": null, - "collection_titles_or_ids_to_exclude__array": null, + "collection_handles_or_ids_to_include__array": null, + "collection_handles_or_ids_to_exclude__array": null, "force_manual_sorting_on_collections__boolean": false, "run_hourly__boolean": false, "run_daily__boolean": false diff --git a/docs/move-out-of-stock-products-to-the-end-of-a-collection/script.liquid b/docs/move-out-of-stock-products-to-the-end-of-a-collection/script.liquid index 3ff9a786..c2afafdf 100644 --- a/docs/move-out-of-stock-products-to-the-end-of-a-collection/script.liquid +++ b/docs/move-out-of-stock-products-to-the-end-of-a-collection/script.liquid @@ -1,16 +1,21 @@ +{% assign base_sort_order = options.base_sort_order__required %} +{% assign collection_handles_or_ids_to_include = options.collection_handles_or_ids_to_include__array %} +{% assign collection_handles_or_ids_to_exclude = options.collection_handles_or_ids_to_exclude__array %} +{% assign force_manual_sorting_on_collections = options.force_manual_sorting_on_collections__boolean %} + {% assign allowed_base_sort_orders = "MANUAL,BEST_SELLING,ALPHA_ASC,ALPHA_DESC,PRICE_DESC,PRICE_ASC,CREATED_DESC,CREATED" | split: "," %} -{% unless allowed_base_sort_orders contains options.base_sort_order__required %} +{% unless allowed_base_sort_orders contains base_sort_order %} {% error %} {{ allowed_base_sort_orders | join: ", " | prepend: "Base sort order must be one of: " | json }} {% enderror %} {% endunless %} {% log %} - {{ options.base_sort_order__required | prepend: "Base sort order for this task run: " | json }} + {{ base_sort_order | prepend: "Base sort order for this task run: " | json }} {% endlog %} -{% assign product_sort_order = options.base_sort_order__required %} +{% assign product_sort_order = base_sort_order %} {% assign reverse_sort = nil %} {% case product_sort_order %} @@ -33,48 +38,97 @@ {% assign reverse_sort = true %} {% endcase %} -{% assign collection_handles_or_ids_to_include = options.collection_handles_or_ids_to_include__array %} -{% assign collection_handles_or_ids_to_exclude = options.collection_handles_or_ids_to_exclude__array %} -{% assign force_manual_sorting_on_collections = options.force_manual_sorting_on_collections__boolean %} +{% comment %} + -- query all collections in the shop; do not use a query filter for IDs or handles here since those configuration fields are optional +{% endcomment %} + +{% assign cursor = nil %} +{% assign collections = array %} + +{% for n in (1..100) %} + {% capture query %} + query { + collections( + first: 250 + after: {{ cursor | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + legacyResourceId + title + handle + sortOrder + } + } + } + {% endcapture %} -{% assign collections = shop.collections %} + {% assign result = query | shopify %} -{% if event.preview %} - {% capture collections_json %} - [ + {% if event.preview %} + {% capture result_json %} { - "id": {{ collection_handles_or_ids_to_include.first | default: "1234567890" | json }}, - "admin_graphql_api_id": "gid://shopify/Collection/1234567890" + "data": { + "collections": { + "nodes": [ + { + "id": "gid://shopify/Collection/1234567890", + "legacyResourceId": {{ collection_handles_or_ids_to_include.first | default: "1234567890" | json }}, + "title": "Samples", + "handle": {{ collection_handles_or_ids_to_include.first | json }}, + "sortOrder": "MANUAL" + } + ] + } + } } - ] - {% endcapture %} + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} - {% assign collections = collections_json | parse_json %} -{% endif %} + {% assign collections = collections | concat: result.data.collections.nodes %} -{% for collection in collections %} - {% assign collection_id_string = "" | append: collection.id %} + {% if result.data.collections.pageInfo.hasNextPage %} + {% assign cursor = result.data.collections.pageInfo.endCursor %} + {% else %} + {% break %} + {% endif %} +{% endfor %} +{% comment %} + -- loop through collections and filter by ID and/or handle as configured +{% endcomment %} + +{% for collection in result.data.collections.nodes %} {% if collection_handles_or_ids_to_include != blank %} - {% unless collection_handles_or_ids_to_include contains collection_id_string + {% unless collection_handles_or_ids_to_include contains collection.legacyResourceId or collection_handles_or_ids_to_include contains collection.handle %} {% continue %} {% endunless %} {% elsif collection_handles_or_ids_to_exclude != blank %} - {% if collection_handles_or_ids_to_exclude contains collection_id_string + {% if collection_handles_or_ids_to_exclude contains collection.legacyResourceId or collection_handles_or_ids_to_exclude contains collection.handle %} {% continue %} {% endif %} {% endif %} - {% if collection.sort_order != "manual" %} - {% if force_manual_sorting_on_collections or event.preview %} + {% comment %} + -- make sure collection is configured for manual sorting, and optionally update it if not + {% endcomment %} + + {% if collection.sortOrder != "MANUAL" %} + {% if force_manual_sorting_on_collections %} {% action "shopify" %} mutation { collectionUpdate( input: { - id: {{ collection.admin_graphql_api_id | json }} + id: {{ collection.id | json }} sortOrder: MANUAL } ) { @@ -95,13 +149,17 @@ {% endif %} {% endif %} + {% comment %} + -- get all product IDs in this collection using the current sort order + {% endcomment %} + {% assign all_product_ids_current_sort = array %} {% assign cursor = nil %} - {% for n in (0..100) %} + {% for n in (1..100) %} {% capture query %} query { - collection(id: {{ collection.admin_graphql_api_id | json }}) { + collection(id: {{ collection.id | json }}) { products( sortKey: COLLECTION_DEFAULT first: 250 @@ -131,18 +189,22 @@ {% endif %} {% endfor %} + {% comment %} + -- get all products in this collection using the configured sort order; get variants later if needed in order to support 2K variant limit + {% endcomment %} + {% assign in_stock_product_ids = array %} {% assign out_of_stock_product_ids = array %} {% assign cursor = nil %} - {% for n in (0..3000) %} + {% for n in (1..100) %} {% capture query %} query { - collection(id: {{ collection.admin_graphql_api_id | json }}) { + collection(id: {{ collection.id | json }}) { products( sortKey: {{ product_sort_order }} reverse: {{ reverse_sort | json }} - first: 9 + first: 250 after: {{ cursor | json }} ) { pageInfo { @@ -152,12 +214,7 @@ nodes { id tracksInventory - variants(first: 100) { - nodes { - inventoryPolicy - inventoryQuantity - } - } + hasOutOfStockVariants } } } @@ -176,46 +233,16 @@ { "id": "gid://shopify/Product/1234567890", "tracksInventory": true, - "variants": { - "nodes": [ - { - "inventoryPolicy": "DENY", - "inventoryQuantity": 0 - } - ] - } + "hasOutOfStockVariants": true }, { "id": "gid://shopify/Product/2345678901", "tracksInventory": true, - "variants": { - "nodes": [ - { - "inventoryPolicy": "CONTINUE", - "inventoryQuantity": 0 - } - ] - } + "hasOutOfStockVariants": false }, { "id": "gid://shopify/Product/3456789012", "tracksInventory": false - }, - { - "id": "gid://shopify/Product/4567890123", - "tracksInventory": true, - "variants": { - "nodes": [ - { - "inventoryPolicy": "DENY", - "inventoryQuantity": 0 - }, - { - "inventoryPolicy": "CONTINUE", - "inventoryQuantity": 0 - } - ] - } } ] } @@ -227,25 +254,88 @@ {% assign result = result_json | parse_json %} {% endif %} + {% comment %} + -- split products into in-stock and out-of-stock buckets, keeping the configured sort order + {% endcomment %} + {% for product in result.data.collection.products.nodes %} {% assign has_in_stock_variant = nil %} - {% unless product.tracksInventory %} + {% unless product.tracksInventory and product.hasOutOfStockVariants %} {% assign has_in_stock_variant = true %} {% else %} - {% for variant in product.variants.nodes %} - {% if variant.inventoryPolicy == "CONTINUE" or variant.inventoryQuantity > 0 %} - {% assign has_in_stock_variant = true %} + {% comment %} + -- query up to 2K variants to see if any of them are in stock; can break as soon as one is found + {% endcomment %} + + {% assign variants_cursor = nil %} + + {% for n in (1..8) %} + {% capture query %} + query { + product(id: {{ product.id | json }}) { + variants( + first: 250 + after: {{ variants_cursor | json }} + ) { + pageInfo { + hasNextPage + endCursor + } + nodes { + id + inventoryPolicy + inventoryQuantity + } + } + } + } + {% endcapture %} + + {% assign result = query | shopify %} + + {% if event.preview %} + {% capture result_json %} + { + "data": { + "product": { + "variants": { + "nodes": [ + { + "id": "gid://shopify/ProductVariant/1234567890", + "inventoryPolicy": "DENY", + "inventoryQuantity": 0 + } + ] + } + } + } + } + {% endcapture %} + + {% assign result = result_json | parse_json %} + {% endif %} + + {% for variant in result.data.product.variants.nodes %} + {% if variant.inventoryPolicy == "CONTINUE" or variant.inventoryQuantity > 0 %} + {% assign has_in_stock_variant = true %} + {% break %} + {% endif %} + {% endfor %} + + {% if result.data.products.variants.pageInfo.hasNextPage and has_in_stock_variant != true %} + {% assign variants_cursor = result.data.products.variants.pageInfo.endCursor %} + {% else %} {% break %} {% endif %} {% endfor %} {% endunless %} {% if has_in_stock_variant %} - {% assign in_stock_product_ids[in_stock_product_ids.size] = product.id %} + {% assign in_stock_product_ids = in_stock_product_ids | push: product.id %} {% else %} - {% assign out_of_stock_product_ids[out_of_stock_product_ids.size] = product.id %} + {% assign out_of_stock_product_ids = out_of_stock_product_ids | push: product.id %} {% endif %} {% endfor %} @@ -256,8 +346,16 @@ {% endif %} {% endfor %} + {% comment %} + -- combine product IDs back into single array with all out of stock variants following the in stock ones, but otherwise keeping the configured sort order + {% endcomment %} + {% assign all_product_ids = in_stock_product_ids | concat: out_of_stock_product_ids %} + {% comment %} + -- determine which product IDs need to be moved by comparing to original sort order + {% endcomment %} + {% assign moves = array %} {% for product_id in all_product_ids %} @@ -265,10 +363,28 @@ {% assign move = hash %} {% assign move["id"] = product_id %} {% assign move["newPosition"] = "" | append: forloop.index0 %} - {% assign moves[moves.size] = move %} + {% assign moves = moves | push: move %} {% endif %} {% endfor %} + {% comment %} + -- move to next collection if no moves are necessary + {% endcomment %} + + {% if moves == blank %} + {% log + message: "No position moves necessary for this collection, everything is already in its appropriate sort order.", + collection: collection.title + %} + {% continue %} + {% endif %} + + {% log + message: "Scheduling job(s) to reorder products for this collection.", + collection: collection.title, + moves_count: moves.size + %} + {% comment %} -- using reverse filter below due to a bug in the collectionReorderProducts mutation -- this filter will NOT affect the sort order determined above @@ -280,9 +396,12 @@ {% action "shopify" %} mutation { collectionReorderProducts( - id: {{ collection.admin_graphql_api_id | json }} + id: {{ collection.id | json }} moves: {{ move_group | graphql_arguments }} ) { + job { + id + } userErrors { field message @@ -290,11 +409,5 @@ } } {% endaction %} - - {% else %} - {% log - message: "No position moves necessary for this collection, everything is already in its appropriate sort order.", - collection: collection.title - %} {% endfor %} {% endfor %} diff --git a/tasks/auto-tag-orders-using-product-tags.json b/tasks/auto-tag-orders-using-product-tags.json index f0fad31d..b4fda3f4 100644 --- a/tasks/auto-tag-orders-using-product-tags.json +++ b/tasks/auto-tag-orders-using-product-tags.json @@ -5,17 +5,18 @@ "online_store_javascript": null, "options": { "only_copy_these_tags__array": null, - "only_copy_tags_having_this_prefix": null + "only_copy_tags_having_one_of_these_prefixes__array": null }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% assign order_ids_tags_and_product_tags = array %}\n\n{% if event.topic == \"shopify/orders/create\" %}\n {% if event.preview %}\n {% capture order_json %}\n {\n \"admin_graphql_api_id\": \"gid://shopify/Order/12345\",\n \"line_items\": [\n {\n \"product\": {\n \"tags\": {{ options.only_copy_these_tags__array | join: \", \" | default: \"preorder\" | json }}\n }\n },\n {\n \"product\": {\n \"tags\": {{ options.only_copy_tags_having_this_prefix | default: \"important\" | append: \"-order\" | json }}\n }\n }\n ]\n }\n {% endcapture %}\n\n {% assign order = order_json | parse_json %}\n {% endif %}\n\n {% assign order_product_tags = order.line_items | map: \"product\" | map: \"tags\" | join: \", \" | split: \", \" %}\n {% assign item = array %}\n {% assign item[0] = order.admin_graphql_api_id %}\n {% assign item[1] = order.tags | split: \", \" %}\n {% assign item[2] = order_product_tags %}\n {% assign order_ids_tags_and_product_tags[0] = item %}\n{% elsif event.topic == \"mechanic/user/trigger\" %}\n {% capture bulk_operation_query %}\n query {\n orders {\n edges {\n node {\n __typename\n id\n tags\n lineItems {\n edges {\n node {\n __typename\n id\n product {\n tags\n }\n }\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% if event.preview %}\n {% capture objects_jsonl %}\n {\"__typename\":\"Order\",\"id\":\"gid:\\/\\/shopify\\/Order\\/1234567890\",\"tags\":[]}\n {\"__typename\":\"LineItem\",\"__parentId\":\"gid:\\/\\/shopify\\/Order\\/1234567890\",\"id\":\"gid:\\/\\/shopify\\/LineItem\\/1234567890\",\"product\":{\"tags\":{{ options.only_copy_these_tags__array | join: \",\" | default: \"preorder\" | split: \",\" | json }}},\"__parentId\":\"gid:\\/\\/shopify\\/Order\\/1234567890\"}\n {\"__typename\":\"LineItem\",\"__parentId\":\"gid:\\/\\/shopify\\/Order\\/1234567890\",\"id\":\"gid:\\/\\/shopify\\/LineItem\\/2345678901\",\"product\":{\"tags\":[{{ options.only_copy_tags_having_this_prefix | default: \"important\" | append: \"-order\" | json }}]},\"__parentId\":\"gid:\\/\\/shopify\\/Order\\/1234567890\"}\n {% endcapture %}\n\n {% assign bulkOperation = hash %}\n {% assign bulkOperation[\"objects\"] = objects_jsonl | parse_jsonl %}\n {% endif %}\n\n {% assign orders = bulkOperation.objects | where: \"__typename\", \"Order\" %}\n {% assign lineItems = bulkOperation.objects | where: \"__typename\", \"LineItem\" %}\n\n {% for order in orders %}\n {% assign order_products = lineItems | where: \"__parentId\", order.id | map: \"product\" | compact %}\n {% assign order_product_tags = array %}\n {% for product in order_products %}\n {% assign order_product_tags = order_product_tags | concat: product.tags | uniq %}\n {% endfor %}\n\n {% assign item = array %}\n {% assign item[0] = order.id %}\n {% assign item[1] = order.tags %}\n {% assign item[2] = order_product_tags %}\n {% assign order_ids_tags_and_product_tags[order_ids_tags_and_product_tags.size] = item %}\n {% endfor %}\n{% endif %}\n\n{% for item in order_ids_tags_and_product_tags %}\n {% assign order_id = item[0] %}\n {% assign order_tags = item[1] %}\n {% assign order_product_tags = item[2] %}\n\n {% assign tags_to_add = array %}\n\n {% for tag in order_product_tags %}\n {% if order_tags contains tag %}\n {% continue %}\n {% endif %}\n\n {% if options.only_copy_these_tags__array != blank %}\n {% if options.only_copy_these_tags__array contains nil %}\n {% error \"'Only copy these tags' must not contain any blank items. If you don't want to use this option, remove its remaining items.\" %}\n {% endif %}\n\n {% unless options.only_copy_these_tags__array contains tag %}\n {% continue %}\n {% endunless %}\n {% endif %}\n\n {% if options.only_copy_tags_having_this_prefix != blank %}\n {% assign prefix_length = options.only_copy_tags_having_this_prefix.size %}\n {% assign tag_substr = tag | slice: 0, prefix_length %}\n {% if tag_substr != options.only_copy_tags_having_this_prefix %}\n {% continue %}\n {% endif %}\n {% endif %}\n\n {% assign tags_to_add[tags_to_add.size] = tag %}\n {% endfor %}\n\n {% if event.preview and tags_to_add == empty %}\n {% error \"It looks like you have conflicting options configured. Please double-check your options, and try again. :)\" %}\n {% endif %}\n\n {% if tags_to_add != empty %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: {{ order_id | json }}\n tags: {{ tags_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n{% endfor %}", + "script": "{% assign only_copy_these_tags = options.only_copy_these_tags__array %}\n{% assign only_copy_tags_having_one_of_these_prefixes = options.only_copy_tags_having_one_of_these_prefixes__array %}\n\n{% comment %}\n -- check configured tags to make sure no inadvertent spaces are present\n{% endcomment %}\n\n{% for tag in only_copy_these_tags %}\n {% assign tag_check = tag | strip %}\n\n {% if tag_check == \"\" %}\n {% error \"'Only copy these tags' contains an empty entry. Please correct to continue.\" %}\n {% endif %}\n{% endfor %}\n\n{% for tag in only_copy_tags_having_one_of_these_prefixes %}\n {% assign tag_check = tag | strip %}\n\n {% if tag_check == \"\" %}\n {% error \"'Only copy tags having one of these prefixes' contains an empty entry. Please correct to continue.\" %}\n {% endif %}\n{% endfor %}\n\n{% assign order_ids_tags_and_product_tags = array %}\n\n{% if event.topic == \"shopify/orders/create\" or event.topic == \"mechanic/user/order\" %}\n {% comment %}\n -- query the order, line items, products, and tags\n {% endcomment %}\n\n {% capture query %}\n query {\n order(id: {{ order.admin_graphql_api_id | json }}) {\n id\n tags\n lineItems(first: 250) {\n nodes {\n product {\n tags\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"order\": {\n \"id\": \"gid://shopify/Order/1234567890\",\n \"lineItems\": {\n \"nodes\": [\n {\n \"product\": {\n \"tags\": [\n \"preview-tag\",\n {{ only_copy_these_tags.first | json }},\n {{ only_copy_tags_having_one_of_these_prefixes.first | json }}\n ]\n }\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign order = result.data.order %}\n\n {% comment %}\n -- save order ID and tags as the only item in the array for processing\n {% endcomment %}\n\n {% assign item = array %}\n {% assign item[0] = order.id %}\n {% assign item[1] = order.tags %}\n {% assign item[2] = order.lineItems.nodes | map: \"product\" | map: \"tags\" | uniq %}\n {% assign order_ids_tags_and_product_tags[0] = item %}\n\n{% elsif event.topic == \"mechanic/user/trigger\" %}\n {% comment %}\n -- use bulk op to get all orders in the shop\n {% endcomment %}\n\n {% capture bulk_operation_query %}\n query {\n orders {\n edges {\n node {\n __typename\n id\n tags\n lineItems {\n edges {\n node {\n __typename\n product {\n tags\n }\n }\n }\n }\n }\n }\n }\n }\n {% endcapture %}\n\n {% action \"shopify\" %}\n mutation {\n bulkOperationRunQuery(\n query: {{ bulk_operation_query | json }}\n ) {\n bulkOperation {\n id\n status\n }\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n\n{% elsif event.topic == \"mechanic/shopify/bulk_operation\" %}\n {% if event.preview %}\n {% capture objects_jsonl %}\n {\"__typename\":\"Order\",\"id\":\"gid:\\/\\/shopify\\/Order\\/1234567890\",\"tags\":[]}\n {\"__typename\":\"LineItem\",\"__parentId\":\"gid:\\/\\/shopify\\/Order\\/1234567890\",\"product\":{\"tags\":[\"preview-tag\",{{ only_copy_these_tags.first | json }},{{ only_copy_tags_having_one_of_these_prefixes.first | json }}]}}\n {% endcapture %}\n\n {% assign bulkOperation = hash %}\n {% assign bulkOperation[\"objects\"] = objects_jsonl | parse_jsonl %}\n {% endif %}\n\n {% assign orders = bulkOperation.objects | where: \"__typename\", \"Order\" %}\n {% assign line_items = bulkOperation.objects | where: \"__typename\", \"LineItem\" %}\n\n {% comment %}\n -- save order IDs and tags in array for processing\n {% endcomment %}\n\n {% for order in orders %}\n {% assign order_product_tags\n = line_items\n | where: \"__parentId\", order.id\n | map: \"product\"\n | map: \"tags\"\n | uniq\n %}\n\n {% assign item = array %}\n {% assign item[0] = order.id %}\n {% assign item[1] = order.tags %}\n {% assign item[2] = order_product_tags %}\n {% assign order_ids_tags_and_product_tags[order_ids_tags_and_product_tags.size] = item %}\n {% endfor %}\n{% endif %}\n\n{% comment %}\n -- process items to see which tags to add to each order\n{% endcomment %}\n\n{% for item in order_ids_tags_and_product_tags %}\n {% assign order_id = item[0] %}\n {% assign order_tags = item[1] %}\n {% assign order_product_tags = item[2] %}\n\n {% assign tags_to_add = array %}\n\n {% for product_tag in order_product_tags %}\n {% if order_tags contains product_tag %}\n {% continue %}\n {% endif %}\n\n {% if only_copy_these_tags == blank and only_copy_tags_having_one_of_these_prefixes == blank %}\n {% assign tags_to_add = tags_to_add | push: product_tag %}\n {% continue %}\n {% endif %}\n\n {% for tag in only_copy_these_tags %}\n {% if tag == product_tag %}\n {% assign tags_to_add = tags_to_add | push: product_tag %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- make sure the prefix matches the beginning of the tag\n {% endcomment %}\n\n {% for tag in only_copy_tags_having_one_of_these_prefixes %}\n {% assign prefix_length = tag.size %}\n {% assign product_tag_substr = product_tag | slice: 0, prefix_length %}\n\n {% if tag == product_tag_substr %}\n {% assign tags_to_add = tags_to_add | push: product_tag %}\n {% endif %}\n {% endfor %}\n {% endfor %}\n\n {% comment %}\n -- remove duplicate tags before adding to order\n {% endcomment %}\n\n {% assign tags_to_add = tags_to_add | compact | uniq %}\n\n {% if tags_to_add != blank %}\n {% action \"shopify\" %}\n mutation {\n tagsAdd(\n id: {{ order_id | json }}\n tags: {{ tags_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n }\n {% endaction %}\n {% endif %}\n{% endfor %}\n", "subscriptions": [ "shopify/orders/create", "mechanic/user/trigger", - "mechanic/shopify/bulk_operation" + "mechanic/shopify/bulk_operation", + "mechanic/user/order" ], - "subscriptions_template": "shopify/orders/create\nmechanic/user/trigger\nmechanic/shopify/bulk_operation", + "subscriptions_template": "shopify/orders/create\nmechanic/user/trigger\nmechanic/shopify/bulk_operation\nmechanic/user/order", "tags": [ "Auto-Tag", "Orders", diff --git a/tasks/auto-tag-products-that-have-a-compare-at-price.json b/tasks/auto-tag-products-that-have-a-compare-at-price.json index a1591308..abebd384 100644 --- a/tasks/auto-tag-products-that-have-a-compare-at-price.json +++ b/tasks/auto-tag-products-that-have-a-compare-at-price.json @@ -1,16 +1,16 @@ { - "docs": "This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that aren't on sale), and Mechanic will take care of applying and removing tags as appropriate. If you're using Shopify discounts, this can allow you to use automatic sale collections – based on these tags – to control eligibility for your discounts.\n\nThis task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that _aren't_ on sale), and Mechanic will take care of applying and removing tags as appropriate.\r\n\r\nRun this task manually to update your entire product catalog at once.", + "docs": "This task will keep your sale tags in sync, without any manual work. Configure the task with a tag to apply (and optionally a tag for products that _aren't_ on sale), and Mechanic will take care of applying and removing tags as appropriate.\n\nRun this task manually to update your entire product catalog at once.", "halt_action_run_sequence_on_error": false, "name": "Auto-tag products that have a \"compare at\" price", "online_store_javascript": null, "options": { - "tag_for_sale_products": "on-sale", + "tag_for_sale_products__required": "on-sale", "tag_for_all_other_products": "not-on-sale", "sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean": true }, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% if options.tag_for_sale_products == blank and options.tag_for_all_other_products == blank %}\n {% error \"Please fill in at least one of the two tag options.\" %}\n{% endif %}\n\n{% if event.preview %}\n {% capture products_json %}\n [\n {\n \"admin_graphql_api_id\": \"gid://shopify/Product/1234567890\",\n \"tags\": \"\",\n \"variants\": [\n {\n \"price\": \"19.99\",\n \"compare_at_price\": \"24.99\"\n }\n ]\n }\n ]\n {% endcapture %}\n\n {% assign products = products_json | parse_json %}\n{% elsif event.topic contains \"shopify/products/\" %}\n {% assign products = array %}\n {% assign products[0] = product %}\n{% elsif event.topic == \"mechanic/user/trigger\" %}\n {% assign products = shop.products %}\n{% endif %}\n\n{% for product in products %}\n {% assign has_compare_at_price = false %}\n {% assign has_sale_price = false %}\n\n {% for variant in product.variants %}\n {% if variant.compare_at_price == blank %}\n {% continue %}\n {% endif %}\n\n {% assign has_compare_at_price = true %}\n\n {% if options.sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean %}\n {% assign price = variant.price | times: 1 %}\n {% assign compare_at_price = variant.compare_at_price | times: 1 %}\n {% if price < compare_at_price %}\n {% assign has_sale_price = true %}\n {% endif %}\n {% endif %}\n {% endfor %}\n\n {% assign product_tags = product.tags | split: \", \" %}\n\n {% assign tag_to_add = nil %}\n {% assign tag_to_remove = nil %}\n\n {% assign product_qualifies_as_sale = false %}\n {% if has_compare_at_price %}\n {% if options.sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean %}\n {% if has_sale_price %}\n {% assign product_qualifies_as_sale = true %}\n {% endif %}\n {% else %}\n {% assign product_qualifies_as_sale = true %}\n {% endif %}\n {% endif %}\n\n {% if product_qualifies_as_sale %}\n {% unless product_tags contains options.tag_for_sale_products %}\n {% assign tag_to_add = options.tag_for_sale_products %}\n {% endunless %}\n\n {% if product_tags contains options.tag_for_all_other_products %}\n {% assign tag_to_remove = options.tag_for_all_other_products %}\n {% endif %}\n {% else %}\n {% if product_tags contains options.tag_for_sale_products %}\n {% assign tag_to_remove = options.tag_for_sale_products %}\n {% endif %}\n\n {% unless product_tags contains options.tag_for_all_other_products %}\n {% assign tag_to_add = options.tag_for_all_other_products %}\n {% endunless %}\n {% endif %}\n\n {% if tag_to_add or tag_to_remove %}\n {% action \"shopify\" %}\n mutation {\n {% if tag_to_add %}\n tagsAdd(\n id: {{ product.admin_graphql_api_id | json }}\n tags: {{ tag_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n\n {% if tag_to_remove %}\n tagsRemove(\n id: {{ product.admin_graphql_api_id | json }}\n tags: {{ tag_to_remove | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n }\n {% endaction %}\n {% endif %}\n{% endfor %}", + "script": "{% assign tag_for_sale_products = options.tag_for_sale_products__required %}\n{% assign tag_for_all_other_products = options.tag_for_all_other_products %}\n{% assign must_have_price_lower_than_the_compare_at_price = options.sale_products_must_have_a_price_lower_than_the_compare_at_price__boolean %}\n\n{% comment %}\n -- for product create/update, filter the products query with the product ID that caused the event\n{% endcomment %}\n\n{% if event.topic == \"shopify/products/update\" %}\n {% assign search_query = product.id | prepend: \"id:\" %}\n{% endif %}\n\n{% comment %}\n -- query product(s) in the shop; variants will be queried separately as needed to support up to 2K variants\n{% endcomment %}\n\n{% assign cursor = nil %}\n{% assign products = array %}\n\n{% for n in (1..100) %}\n {% capture query %}\n query {\n products(\n first: 250\n after: {{ cursor | json }}\n query: {{ search_query | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n id\n tags\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"products\": {\n \"nodes\": [\n {\n \"id\": \"gid://shopify/Product/1234567890\"\n }\n ]\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign products = products | concat: result.data.products.nodes %}\n\n {% if result.data.products.pageInfo.hasNextPage %}\n {% assign cursor = result.data.products.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n{% endfor %}\n\n{% comment %}\n -- process each product by querying as many variants as needed to determine which tag applies\n{% endcomment %}\n\n{% for product in products %}\n {% assign product_qualifies_as_sale = nil %}\n {% assign cursor = nil %}\n\n {% for n in (1..8) %}\n {% capture query %}\n query {\n product(id: {{ product.id | json }}) {\n variants(\n first: 250\n after: {{ cursor | json }}\n ) {\n pageInfo {\n hasNextPage\n endCursor\n }\n nodes {\n price\n compareAtPrice\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = query | shopify %}\n\n {% if event.preview %}\n {% capture result_json %}\n {\n \"data\": {\n \"product\": {\n \"variants\": {\n \"nodes\": [\n {\n \"price\": \"19.99\",\n \"compareAtPrice\": \"24.99\"\n }\n ]\n }\n }\n }\n }\n {% endcapture %}\n\n {% assign result = result_json | parse_json %}\n {% endif %}\n\n {% assign variants = result.data.product.variants.nodes %}\n\n {% comment %}\n -- check batch of variants to see if a compare at price exists, and optionally if it is greater than the price\n {% endcomment %}\n\n {% for variant in variants %}\n {% if variant.compareAtPrice == blank %}\n {% continue %}\n {% endif %}\n\n {% if must_have_price_lower_than_the_compare_at_price %}\n {% assign price = variant.price | times: 1 %}\n {% assign compare_at_price = variant.compareAtPrice | times: 1 %}\n\n {% if price < compare_at_price %}\n {% assign product_qualifies_as_sale = true %}\n {% break %}\n {% endif %}\n\n {% else %}\n {% assign product_qualifies_as_sale = true %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- only query for more variants if the product has not yet qualified as \"on sale\" AND if there are more variants\n {% endcomment %}\n\n {% if product_qualifies_as_sale %}\n {% break %}\n {% elsif result.data.product.variants.pageInfo.hasNextPage %}\n {% assign cursor = result.data.product.variants.pageInfo.endCursor %}\n {% else %}\n {% break %}\n {% endif %}\n {% endfor %}\n\n {% comment %}\n -- adjust tags on product as needed\n {% endcomment %}\n\n {% assign tag_to_add = nil %}\n {% assign tag_to_remove = nil %}\n\n {% if product_qualifies_as_sale %}\n {% if tag_for_sale_products != blank %}\n {% unless product.tags contains tag_for_sale_products %}\n {% assign tag_to_add = tag_for_sale_products %}\n {% endunless %}\n {% endif %}\n\n {% if tag_for_all_other_products != blank %}\n {% if product.tags contains tag_for_all_other_products %}\n {% assign tag_to_remove = tag_for_all_other_products %}\n {% endif %}\n {% endif %}\n\n {% else %}\n {% if tag_for_sale_products != blank %}\n {% if product.tags contains tag_for_sale_products %}\n {% assign tag_to_remove = tag_for_sale_products %}\n {% endif %}\n {% endif %}\n\n {% if tag_for_all_other_products != blank %}\n {% unless product.tags contains tag_for_all_other_products %}\n {% assign tag_to_add = tag_for_all_other_products %}\n {% endunless %}\n {% endif %}\n {% endif %}\n\n {% if tag_to_add or tag_to_remove %}\n {% action \"shopify\" %}\n mutation {\n {% if tag_to_add %}\n tagsAdd(\n id: {{ product.id | json }}\n tags: {{ tag_to_add | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n\n {% if tag_to_remove %}\n tagsRemove(\n id: {{ product.id | json }}\n tags: {{ tag_to_remove | json }}\n ) {\n userErrors {\n field\n message\n }\n }\n {% endif %}\n }\n {% endaction %}\n {% endif %}\n{% endfor %}\n", "subscriptions": [ "shopify/products/create", "shopify/products/update", @@ -20,6 +20,7 @@ "tags": [ "Auto-Tag", "Compare at", - "Products" + "Products", + "Sale" ] } diff --git a/tasks/generate-a-simple-product-catalog-pdf.json b/tasks/generate-a-simple-product-catalog-pdf.json index 86f55b62..f018ff0d 100644 --- a/tasks/generate-a-simple-product-catalog-pdf.json +++ b/tasks/generate-a-simple-product-catalog-pdf.json @@ -1,20 +1,20 @@ { - "docs": "This task is a simple demonstration of pulling product titles and images into a PDF, resulting in a simple catalog of the products available in your store. Use it as a starting point for building something more complex.\r\n\r\nRun this task manually to generate the PDF.", + "docs": "This task is a simple demonstration of pulling product titles and images into a PDF, resulting in a simple catalog of the products available in your store. Use it as a starting point for building something more complex.\n\nRun this task manually to generate the PDF.", "halt_action_run_sequence_on_error": false, "name": "Generate a simple product catalog PDF", "online_store_javascript": null, "options": {}, "order_status_javascript": null, "perform_action_runs_in_sequence": false, - "script": "{% capture html %}\n

    Product catalog

    \n