diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index aedb60c3..c5bf8a6d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -3,7 +3,8 @@ on:
workflow_dispatch:
push:
branches:
- - '7.x'
+ - '8.x'
+ - '8.0'
pull_request:
permissions:
contents: read
diff --git a/.github/workflows/craft4-ci.yml b/.github/workflows/craft4-ci.yml
deleted file mode 100644
index f1f0bf41..00000000
--- a/.github/workflows/craft4-ci.yml
+++ /dev/null
@@ -1,78 +0,0 @@
-name: craft4-ci
-on:
- workflow_dispatch:
- push:
- branches:
- - '7.x'
- pull_request:
-permissions:
- contents: read
-concurrency:
- group: craft4-ci-${{ github.ref }}
- cancel-in-progress: true
-jobs:
- code-quality:
- name: code-quality (Craft 4)
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- - uses: shivammathur/setup-php@v2
- with:
- php-version: '8.2'
- tools: composer:2.7
- - uses: actions/setup-node@v4
- with:
- node-version: '20'
- cache: npm
- - name: Install Craft 4 dependencies
- run: composer update --no-interaction --no-audit --no-progress --prefer-dist --with craftcms/cms:^4 --with-all-dependencies
- - name: Run ECS
- run: composer check-cs
- - name: Run PHPStan
- run: vendor/bin/phpstan analyse --configuration=phpstan.craft4.neon --memory-limit=1G
- - name: Install Node dependencies
- run: npm ci
- - name: Run Prettier
- run: npm run check-prettier
-
- tests:
- name: tests (Craft 4, ${{ matrix.db }})
- runs-on: ubuntu-latest
- strategy:
- matrix:
- db: [mysql, pgsql]
- include:
- - db: mysql
- env_file: tests/.env.example.mysql
- - db: pgsql
- env_file: tests/.env.example.pgsql
- services:
- mysql:
- image: mysql:8.0
- env:
- MYSQL_ALLOW_EMPTY_PASSWORD: yes
- MYSQL_DATABASE: craft_test
- ports:
- - 3306:3306
- options: --health-cmd="mysqladmin ping" --health-interval=10s --health-timeout=5s --health-retries=3
- postgres:
- image: postgres:16
- env:
- POSTGRES_USER: root
- POSTGRES_DB: craft_test
- POSTGRES_HOST_AUTH_METHOD: trust
- ports:
- - 5432:5432
- options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
- steps:
- - uses: actions/checkout@v4
- - uses: shivammathur/setup-php@v2
- with:
- php-version: '8.2'
- tools: composer:2.7
- - name: Install Craft 4 dependencies
- run: composer update --no-interaction --no-audit --no-progress --prefer-dist --with craftcms/cms:^4 --with-all-dependencies
- - name: Create test environment
- run: cp ${{ matrix.env_file }} tests/.env
- - name: Run Tests
- run: composer run testunit
diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md
new file mode 100644
index 00000000..80419948
--- /dev/null
+++ b/CHANGELOG-WIP.md
@@ -0,0 +1,48 @@
+# WIP Release Notes for Shopify 8.0
+
+> [!IMPORTANT]
+> If you change the **Additional Features** or **Custom Scopes** settings after the app is already authorized, you must update the scopes in your Shopify app configuration and then re-authorize the app.
+
+### Store Management
+
+- Added support for syncing product translations from Shopify. ([#215](https://github.com/craftcms/shopify/issues/215))
+- It’s now possible to view the required API scopes in the plugin settings.
+- It’s now possible to extend the API scopes with opt-in additional features and custom scopes.
+- It’s now possible to customize the Shopify API context before and after initialization via new events.
+
+### Extensibility
+
+- Added `craft\shopify\controllers\SettingsController::actionGetScopes()`.
+- Added `craft\shopify\events\DefineContextConfigEvent`.
+- Added `craft\shopify\models\BulkOperation::$shopifyGid`.
+- Added `craft\shopify\models\Settings::REQUIRED_SCOPES`.
+- Added `craft\shopify\models\Settings::getAdditionalFeatures()`.
+- Added `craft\shopify\models\Settings::getAdditionalFeaturesOptions()`.
+- Added `craft\shopify\models\Settings::getCustomScopes()`.
+- Added `craft\shopify\models\Settings::getScopes()`.
+- Added `craft\shopify\models\Settings::setAdditionalFeatures()`.
+- Added `craft\shopify\models\Settings::setCustomScopes()`.
+- Added `craft\shopify\models\Variant::$shopifyGid`.
+- Added `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyGid`.
+- Added `craft\shopify\services\BulkOperations::getBulkOperationByShopifyGid()`.
+- Added `craft\shopify\services\Products::deleteProductByShopifyGid()`.
+- Added `craft\shopify\services\Products::deleteShopifyDataByShopifyGid()`.
+- Added `craft\shopify\services\Products::syncProductByShopifyGid()`.
+- Added `craft\shopify\services\Api::EVENT_CONTEXT_INITIALIZED`.
+- Added `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG`.
+- Added `craft\shopify\services\Api::getShopLocalesGql()`.
+- `craft\shopify\models\Variant::$shopifyId` now holds the numeric Shopify ID. The full GID is now available via `$shopifyGid`.
+- `craft\shopify\records\ShopifyData::$shopifyId` is now a generated (read-only) column containing the numeric Shopify ID. The full GID is now available via `$shopifyGid`.
+- Renamed `craft\shopify\jobs\ProcessBulkOperationData::$bulkOperationShopifyId` to `$bulkOperationShopifyGid`.
+- Renamed `craft\shopify\models\BulkOperation::$shopifyId` to `$shopifyGid`.
+- Deprecated `craft\shopify\services\BulkOperations::getBulkOperationByShopifyId()`. Use `getBulkOperationByShopifyGid()` instead.
+- Deprecated `craft\shopify\services\Products::deleteProductByShopifyId()`. Use `deleteProductByShopifyGid()` instead.
+- Deprecated `craft\shopify\services\Products::deleteShopifyDataByShopifyId()`. Use `deleteShopifyDataByShopifyGid()` instead.
+- Deprecated `craft\shopify\services\Products::syncProductByShopifyId()`. Use `syncProductByShopifyGid()` instead.
+
+### System
+
+- The `shopify_data` table's `shopifyId` column has been renamed to `shopifyGid`. A new generated `shopifyId` column (the numeric ID at the end of the GID) has been added.
+- The `shopify_bulkoperations` table's `shopifyId` column has been renamed to `shopifyGid`.
+- Fixed a bug where validation errors for the "Context Pricing Countries" setting weren't displaying correctly.
+- Shopify for Craft now requires Craft CMS 5.10.7 or later.
diff --git a/README.md b/README.md
index 6a8456bd..f52cf249 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ Build a content-driven storefront by synchronizing [Shopify](https://shopify.com
## Installation
-Shopify requires Craft CMS 4.15.0+ or 5.0.0+.
+Shopify requires Craft CMS 5.10.7+.
To install the plugin, visit the [Plugin Store](https://plugins.craftcms.com/shopify) from your Craft project, or follow these instructions.
@@ -72,14 +72,15 @@ To install an app into a store, one of these statements must describe your accou
```bash
SHOPIFY_WEBHOOK_VERSION="2026-01"
```
- - **Access** → **Scopes**: The following scopes are required for the plugin to function correctly:
+ - **Access** → **Scopes**: The following scopes are always required:
- `read_inventory`
- `read_product_listings`
- `read_products`
- - Shopify requires these to be in a comma-separated list:
- ```
- read_inventory,read_product_listings,read_products
- ```
+
+ If you plan to enable any **Additional Features** or **Custom Scopes** in the plugin settings, those will require additional scopes. Once the plugin is installed and configured, use the read-only **Scopes** field in **Shopify** → **Settings** as the source of truth: it always reflects the full, comma-separated string to paste here.
+
+ > [!WARNING]
+ > If you later change your **Additional Features** or **Custom Scopes** settings, you must update the scopes in your Shopify app configuration and then re-authorize the app from the Craft control panel.
- Do _not_ enable the **Use legacy install flow** as it can result in mismatched scopes during installation.
1. Press **Release** to deploy the configuration. You may give it a name and description, or let Shopify tag it with an incrementing number.
1. Switch to the **Settings** screen of the new app, and copy the credentials into your `.env` file:
@@ -208,6 +209,20 @@ Discover orphaned subscriptions using the [`webhookSubscriptions()`](https://sho
## Upgrading
+### From 7.x
+
+> [!WARNING]
+> Ensure the Craft queue is fully drained before upgrading. Any pending sync jobs will be unable to update their status after the migration runs.
+
+Shopify for Craft 8.0 requires **Craft CMS 5.10.7 or later** and drops support for Craft 4.
+
+`craft\shopify\models\Variant::$shopifyId` now holds only the **numeric** Shopify ID (e.g. `”123456789”`). The full GID (e.g. `”gid://shopify/ProductVariant/123456789”`) is available via the new `$shopifyGid` property. Update any templates or custom code that compared or used `$variant->shopifyId` as a GID string.
+
+> [!TIP]
+> The [changelog](https://github.com/craftcms/shopify/blob/8.x/CHANGELOG.md) contains a full list of added, changed, and deprecated classes and methods.
+
+### From 6.x
+
This version (7.x) is primarily concerned with Shopify API compatibility, but the [new authentication mechanism](#connect-to-shopify) means that you’ll need to re-establish the connection to Shopify using the authentication scheme [described above](#connect-to-shopify).
Due to significant shifts in Shopify’s developer ecosystem, many of the [front-end cart management](#front-end-sdks) techniques we have recommended (like the _JS Buy SDK_ and _Buy Button JS_) are no longer viable.
@@ -1220,21 +1235,64 @@ This section describes advanced ways to customize the plugin’s behavior.
The following settings can also be set via a `shopify.php` file in your `config/` directory.
-| Setting | Type | Default | Description |
-|------------------------------|----------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
-| `apiKey` | `string` | — | Shopify API key. |
-| `apiSecretKey` | `string` | — | Shopify API secret key. |
-| `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. |
-| `accessToken` | `string` | — | Shopify API access token. |
-| `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. |
-| `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. |
-| `uriFormat` | `string` | — | Product element URI format. |
-| `template` | `string` | — | Product element template path. |
+| Setting | Type | Default | Description |
+|------------------------------|------------|---------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
+| `apiKey` | `string` | — | Shopify API key. |
+| `apiSecretKey` | `string` | — | Shopify API secret key. |
+| `apiVersion` | `string` | — | Shopify [API version](https://shopify.dev/docs/api/usage/versioning) description. |
+| `accessToken` | `string` | — | Shopify API access token. |
+| `additionalFeatures` | `string[]` | `[]` | Array of additional feature handles to enable (e.g. `['productTranslations']`). Enabling features may add required API scopes; see [Additional Features](#additional-features). |
+| `contextualPricingCountries` | `string` | — | Comma-separated list of [two-letter country codes](https://shopify.dev/docs/api/admin-graphql/2026-01/enums/CountryCode) that determine which [contextual prices](https://shopify.dev/docs/api/admin-graphql/2026-01/objects/ProductVariant#field-ProductVariant.fields.contextualPricing) are loaded via the API. |
+| `customScopes` | `string` | — | Comma-separated list of additional API scopes to request beyond the plugin's [required scopes](#create-an-app). |
+| `hostName` | `string` | — | Your store’s hostname. See the [creating an app](#create-an-app) section for more information. |
+| `uriFormat` | `string` | — | Product element URI format. |
+| `template` | `string` | — | Product element template path. |
> [!NOTE]
> Setting `apiKey`, `apiSecretKey`, `apiVersion`, `accessToken`, or `hostName` via `shopify.php` will override Project Config values set via the control panel during [app setup](#connect-to-shopify).
> You can still reference environment values from the config file with `craft\helpers\App::env()`.
+### Additional Features
+
+Additional features are opt-in capabilities that extend the plugin's default behavior.
+
+> [!WARNING]
+> Enabling or disabling additional features may change the required API scopes! After saving the settings, you must update the **Access** → **Scopes** field in your Shopify app configuration and then re-authorize the app from the Craft control panel, _in each environment_.
+
+Features can also be enabled via `config/shopify.php`:
+
+```php
+return [
+ 'additionalFeatures' => ['productTranslations'],
+];
+```
+
+#### Product Translations
+
+**Handle:** `productTranslations` | **Required scope:** `read_locales`
+
+When enabled, the plugin fetches Shopify's published store locales during product sync and includes translation data for each non-primary locale in the product's raw data. This lets you surface translated product content (titles, descriptions, etc.).
+
+Translation data is stored in `product.getData()`, keyed by locale, like `translations_fr` for French. Each entry is an array of `key`/`value` pairs corresponding to Shopify's [translatable resource keys](https://shopify.dev/docs/api/admin-graphql/latest/objects/Translation).
+
+```twig
+{# Loop over French translations for a product #}
+{% set translations = product.getData()['translations_fr'] ?? [] %}
+{% for translation in translations %}
+
{{ translation.key }}: {{ translation.value }}
+{% endfor %}
+```
+
+To make this data easier to work with, consider indexing it by `key`:
+
+```twig
+{% set translationsByKey = collect(product.getData()['translations_fr'])
+ .keyBy('key')
+ .mapWithKeys((item, k) => { (k): item.value }) %}
+
+{{ translationsByKey.title ?? product.title }}
+```
+
### Emulate Sales Channels
Private apps no longer come with a sales channel that allows merchants to selectively expose products to the Craft integration.
@@ -1380,6 +1438,48 @@ Event::on(
Using this event, after the queries have been built, you have the opportunity to add custom arguments to the main query. For example, you can tailor a query for products using the [ProductConnection arguments](https://shopify.dev/docs/api/admin-graphql/2026-01/queries/products#arguments) (like `query`, `reverse`, or `savedSearchId`).
+#### `craft\shopify\services\Api::EVENT_DEFINE_CONTEXT_CONFIG`
+
+Emitted before the Shopify API context is initialized. The `craft\shopify\events\DefineContextConfigEvent` object exposes a `$config` array containing the arguments that will be passed to [`Context::initialize()`](https://github.com/Shopify/shopify-api-php/blob/main/docs/getting_started.md), allowing you to customize the context before it is applied.
+
+The event object has one property:
+
+- `config`: Array of arguments passed to `Context::initialize()`, including `apiKey`, `apiSecretKey`, `scopes`, `hostName`, `sessionStorage`, `apiVersion`, `isEmbeddedApp`, and `logger`.
+
+```php
+use craft\base\Event;
+use craft\shopify\events\DefineContextConfigEvent;
+use craft\shopify\services\Api;
+
+Event::on(
+ Api::class,
+ Api::EVENT_DEFINE_CONTEXT_CONFIG,
+ function(DefineContextConfigEvent $event) {
+ // Disable the Shopify API logger:
+ $event->config['logger'] = null;
+ }
+);
+```
+
+#### `craft\shopify\services\Api::EVENT_CONTEXT_INITIALIZED`
+
+Emitted after the Shopify API context has been fully initialized. Use this event to perform setup that depends on a ready context, such as overriding the HTTP client factory.
+
+```php
+use craft\base\Event;
+use craft\shopify\services\Api;
+use Shopify\Context;
+
+Event::on(
+ Api::class,
+ Api::EVENT_CONTEXT_INITIALIZED,
+ function(Event $event) {
+ // Replace the HTTP client factory with a custom implementation:
+ Context::$HTTP_CLIENT_FACTORY = new MyHttpClientFactory();
+ }
+);
+```
+
### GraphQL Playground
In addition to the [template helper](#api-service), you can execute queries against the Admin GraphQL API via Craft’s CLI:
diff --git a/composer.json b/composer.json
index e84111da..4e6cd3f2 100644
--- a/composer.json
+++ b/composer.json
@@ -22,14 +22,14 @@
"require": {
"php": "^8.2",
"carnage/php-graphql-client": "^1.14",
- "craftcms/cms": "^5.0.0-beta.10||^4.15.0",
+ "craftcms/cms": "^5.10.7",
"shopify/shopify-api": "^6.0.0"
},
"require-dev": {
"codeception/codeception": "^5.0.11",
"codeception/module-asserts": "^3.0.0",
"codeception/module-yii2": "^1.1.9",
- "craftcms/feed-me": "^6.6.1||^5.9.0",
+ "craftcms/feed-me": "^6.6.1",
"craftcms/ecs": "dev-main",
"craftcms/phpstan": "dev-main",
"craftcms/rector": "dev-main",
diff --git a/composer.lock b/composer.lock
index 83825d27..b7b8331d 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
- "content-hash": "4c4bd3bcadf528b01b98cf14d2514119",
+ "content-hash": "1e11372d30c0d8dfb70aa25e285717a4",
"packages": [
{
"name": "bacon/bacon-qr-code",
@@ -63,16 +63,16 @@
},
{
"name": "brick/math",
- "version": "0.17.0",
+ "version": "0.17.2",
"source": {
"type": "git",
"url": "https://github.com/brick/math.git",
- "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee"
+ "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/brick/math/zipball/a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee",
- "reference": "a62af7ab2e3cee9f9bf4cf77a5d1e6ba408a44ee",
+ "url": "https://api.github.com/repos/brick/math/zipball/8189e751995f9e15729c1aa2f89fa8f166ffe818",
+ "reference": "8189e751995f9e15729c1aa2f89fa8f166ffe818",
"shasum": ""
},
"require": {
@@ -110,7 +110,7 @@
],
"support": {
"issues": "https://github.com/brick/math/issues",
- "source": "https://github.com/brick/math/tree/0.17.0"
+ "source": "https://github.com/brick/math/tree/0.17.2"
},
"funding": [
{
@@ -118,7 +118,7 @@
"type": "github"
}
],
- "time": "2026-03-17T12:54:54+00:00"
+ "time": "2026-05-25T20:34:43+00:00"
},
{
"name": "carbonphp/carbon-doctrine-types",
@@ -538,16 +538,16 @@
},
{
"name": "craftcms/cms",
- "version": "5.10.5",
+ "version": "5.10.8",
"source": {
"type": "git",
"url": "https://github.com/craftcms/cms.git",
- "reference": "f781aecea946641c1c66baa5cf4a39872f99e7c6"
+ "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/craftcms/cms/zipball/f781aecea946641c1c66baa5cf4a39872f99e7c6",
- "reference": "f781aecea946641c1c66baa5cf4a39872f99e7c6",
+ "url": "https://api.github.com/repos/craftcms/cms/zipball/ec387a529a7f9a0c07f2a086c59dadc02fc79556",
+ "reference": "ec387a529a7f9a0c07f2a086c59dadc02fc79556",
"shasum": ""
},
"require": {
@@ -556,6 +556,7 @@
"composer/semver": "^3.3.2",
"craftcms/plugin-installer": "~1.6.0",
"craftcms/server-check": "~5.1.0",
+ "craftcms/url-validator": "^1.0",
"creocoder/yii2-nested-sets": "~0.9.0",
"elvanto/litemoji": "~4.3.0",
"enshrined/svg-sanitize": "~0.22.0",
@@ -663,7 +664,7 @@
"rss": "https://github.com/craftcms/cms/releases.atom",
"source": "https://github.com/craftcms/cms"
},
- "time": "2026-06-02T18:34:00+00:00"
+ "time": "2026-06-23T11:02:47+00:00"
},
{
"name": "craftcms/plugin-installer",
@@ -760,6 +761,62 @@
},
"time": "2026-04-07T16:48:35+00:00"
},
+ {
+ "name": "craftcms/url-validator",
+ "version": "1.0.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/craftcms/url-validator.git",
+ "reference": "75b44bc4d3f89feb9410b85d385f01210edd5eb1"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/craftcms/url-validator/zipball/75b44bc4d3f89feb9410b85d385f01210edd5eb1",
+ "reference": "75b44bc4d3f89feb9410b85d385f01210edd5eb1",
+ "shasum": ""
+ },
+ "require": {
+ "ext-filter": "*",
+ "php": "^8.0.2"
+ },
+ "require-dev": {
+ "laravel/pint": "^1.29",
+ "nunomaduro/collision": "^8.1",
+ "pestphp/pest": "^3.0",
+ "phpstan/phpstan": "^2.0"
+ },
+ "type": "library",
+ "autoload": {
+ "psr-4": {
+ "CraftCms\\UrlValidator\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Pixel & Tonic",
+ "homepage": "https://pixelandtonic.com/"
+ }
+ ],
+ "description": "Validate URLs and IP addresses against SSRF, DNS rebinding, and cloud-metadata attacks.",
+ "homepage": "https://github.com/craftcms/url-validator",
+ "keywords": [
+ "IP",
+ "craftcms",
+ "security",
+ "ssrf",
+ "url",
+ "validation"
+ ],
+ "support": {
+ "issues": "https://github.com/craftcms/url-validator/issues",
+ "source": "https://github.com/craftcms/url-validator/tree/1.0.0"
+ },
+ "time": "2026-06-15T17:29:09+00:00"
+ },
{
"name": "creocoder/yii2-nested-sets",
"version": "0.9.0",
@@ -1439,22 +1496,22 @@
},
{
"name": "guzzlehttp/guzzle",
- "version": "7.11.1",
+ "version": "7.12.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/guzzle.git",
- "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c"
+ "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/guzzle/zipball/5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
- "reference": "5af96f374e0ab4ebd747b8310888c99d3adb0a8c",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d34627490fbc03bf5c5d7cfed81f2faa19519425",
+ "reference": "d34627490fbc03bf5c5d7cfed81f2faa19519425",
"shasum": ""
},
"require": {
"ext-json": "*",
"guzzlehttp/promises": "^2.5",
- "guzzlehttp/psr7": "^2.11",
+ "guzzlehttp/psr7": "^2.12.1",
"php": "^7.2.5 || ^8.0",
"psr/http-client": "^1.0",
"symfony/deprecation-contracts": "^2.5 || ^3.0",
@@ -1467,7 +1524,7 @@
"bamarni/composer-bin-plugin": "^1.8.2",
"ext-curl": "*",
"guzzle/client-integration-tests": "3.0.2",
- "guzzlehttp/test-server": "^0.5",
+ "guzzlehttp/test-server": "^0.5.1",
"php-http/message-factory": "^1.1",
"phpunit/phpunit": "^8.5.52 || ^9.6.34",
"psr/log": "^1.1 || ^2.0 || ^3.0"
@@ -1547,7 +1604,7 @@
],
"support": {
"issues": "https://github.com/guzzle/guzzle/issues",
- "source": "https://github.com/guzzle/guzzle/tree/7.11.1"
+ "source": "https://github.com/guzzle/guzzle/tree/7.12.1"
},
"funding": [
{
@@ -1563,7 +1620,7 @@
"type": "tidelift"
}
],
- "time": "2026-06-07T22:54:06+00:00"
+ "time": "2026-06-18T14:12:49+00:00"
},
{
"name": "guzzlehttp/promises",
@@ -1651,16 +1708,16 @@
},
{
"name": "guzzlehttp/psr7",
- "version": "2.11.0",
+ "version": "2.12.1",
"source": {
"type": "git",
"url": "https://github.com/guzzle/psr7.git",
- "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f"
+ "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/guzzle/psr7/zipball/bbb5e61349fa5cb822b3e87842b951088b76b81f",
- "reference": "bbb5e61349fa5cb822b3e87842b951088b76b81f",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7",
+ "reference": "172ef2f4e9824c1e058b7f30be8ae25a02c0f2b7",
"shasum": ""
},
"require": {
@@ -1750,7 +1807,7 @@
],
"support": {
"issues": "https://github.com/guzzle/psr7/issues",
- "source": "https://github.com/guzzle/psr7/tree/2.11.0"
+ "source": "https://github.com/guzzle/psr7/tree/2.12.1"
},
"funding": [
{
@@ -1766,7 +1823,7 @@
"type": "tidelift"
}
],
- "time": "2026-06-02T12:30:48+00:00"
+ "time": "2026-06-18T09:49:37+00:00"
},
{
"name": "illuminate/collections",
@@ -4060,20 +4117,20 @@
},
{
"name": "ramsey/uuid",
- "version": "4.x-dev",
+ "version": "4.9.3",
"source": {
"type": "git",
"url": "https://github.com/ramsey/uuid.git",
- "reference": "1a1f98b037d664d9093a620cfa85305c7e50b244"
+ "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/ramsey/uuid/zipball/1a1f98b037d664d9093a620cfa85305c7e50b244",
- "reference": "1a1f98b037d664d9093a620cfa85305c7e50b244",
+ "url": "https://api.github.com/repos/ramsey/uuid/zipball/1df15849d00943a67d677dc9cfd80795f038c9f8",
+ "reference": "1df15849d00943a67d677dc9cfd80795f038c9f8",
"shasum": ""
},
"require": {
- "brick/math": ">=0.8.16 <=0.17",
+ "brick/math": ">=0.8.16 <=0.18",
"php": "^8.0",
"ramsey/collection": "^1.2 || ^2.0"
},
@@ -4106,7 +4163,6 @@
"paragonie/random-lib": "Provides RandomLib for use with the RandomLibAdapter",
"ramsey/uuid-doctrine": "Allows the use of Ramsey\\Uuid\\Uuid as Doctrine field type."
},
- "default-branch": true,
"type": "library",
"extra": {
"captainhook": {
@@ -4133,9 +4189,9 @@
],
"support": {
"issues": "https://github.com/ramsey/uuid/issues",
- "source": "https://github.com/ramsey/uuid/tree/4.x"
+ "source": "https://github.com/ramsey/uuid/tree/4.9.3"
},
- "time": "2026-04-27T22:11:13+00:00"
+ "time": "2026-06-18T03:57:49+00:00"
},
{
"name": "samdark/yii2-psr-log-target",
@@ -7523,16 +7579,16 @@
},
{
"name": "webmozart/assert",
- "version": "2.4.0",
+ "version": "2.4.1",
"source": {
"type": "git",
"url": "https://github.com/webmozarts/assert.git",
- "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155"
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70"
},
"dist": {
"type": "zip",
- "url": "https://api.github.com/repos/webmozarts/assert/zipball/9007ea6f45ecf352a9422b36644e4bfc039b9155",
- "reference": "9007ea6f45ecf352a9422b36644e4bfc039b9155",
+ "url": "https://api.github.com/repos/webmozarts/assert/zipball/2ccb7c2e821038c03a3e6e1700c570c158c55f70",
+ "reference": "2ccb7c2e821038c03a3e6e1700c570c158c55f70",
"shasum": ""
},
"require": {
@@ -7583,9 +7639,9 @@
],
"support": {
"issues": "https://github.com/webmozarts/assert/issues",
- "source": "https://github.com/webmozarts/assert/tree/2.4.0"
+ "source": "https://github.com/webmozarts/assert/tree/2.4.1"
},
- "time": "2026-05-20T13:07:01+00:00"
+ "time": "2026-06-15T15:31:57+00:00"
},
{
"name": "yiisoft/yii2",
diff --git a/phpstan.craft4.neon b/phpstan.craft4.neon
deleted file mode 100644
index 030a133f..00000000
--- a/phpstan.craft4.neon
+++ /dev/null
@@ -1,26 +0,0 @@
-includes:
- - phpstan.neon
-
-parameters:
- # This config is only for the Craft 4 CI run.
- # These ignores cover Craft 4 compatibility shims where PHPStan analyzes against
- # a single runtime API surface and can report false positives for Craft 5-only APIs.
- reportUnmatchedIgnoredErrors: false
- excludePaths:
- analyse:
- - src/linktypes/Product.php
- ignoreErrors:
- -
- message: '#Call to an undefined static method craft\\base\\Element::attributeHtml\(\)\.#'
- path: src/elements/Product.php
- -
- message: '#Call to an undefined method craft\\web\\Response::noticeHtml\(\)\.#'
- path: src/controllers/SettingsController.php
- -
- message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::conditionRuleTypes\(\)\.#'
- path: src/elements/conditions/products/ProductCondition.php
- -
- message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::selectableConditionRules\(\)\.#'
- path: src/elements/conditions/products/ProductCondition.php
-
-
diff --git a/phpstan.neon b/phpstan.neon
index b5576300..44554e4f 100644
--- a/phpstan.neon
+++ b/phpstan.neon
@@ -5,8 +5,3 @@ parameters:
level: 5
paths:
- src
- # Remove this ignore once Craft 4 support is dropped.
- ignoreErrors:
- -
- message: '#Call to an undefined static method craft\\elements\\conditions\\ElementCondition::conditionRuleTypes\(\)\.#'
- path: src/elements/conditions/products/ProductCondition.php
diff --git a/src/Plugin.php b/src/Plugin.php
index 9e186b4f..9a6eee2b 100644
--- a/src/Plugin.php
+++ b/src/Plugin.php
@@ -79,7 +79,7 @@ class Plugin extends BasePlugin
/**
* @var string
*/
- public string $schemaVersion = '7.1.0.0';
+ public string $schemaVersion = '8.0.0';
/**
* @inheritdoc
@@ -450,8 +450,8 @@ private function _registerGarbageCollection(): void
'products.shopifyId',
])
->from(Table::PRODUCTS . ' products')
- ->leftJoin(Table::DATA . ' data', '[[data.shopifyId]] = [[products.shopifyGid]]')
- ->where(['data.shopifyId' => null])
+ ->leftJoin(Table::DATA . ' data', '[[data.shopifyGid]] = [[products.shopifyGid]]')
+ ->where(['data.shopifyGid' => null])
->all();
$shopifyIds = ArrayHelper::getColumn($shopifyProductElementsMissingData, 'shopifyId');
diff --git a/src/collections/VariantCollection.php b/src/collections/VariantCollection.php
index a489f649..fd381c32 100644
--- a/src/collections/VariantCollection.php
+++ b/src/collections/VariantCollection.php
@@ -42,6 +42,7 @@ public static function make($items = [])
$item = Craft::createObject([
'class' => Variant::class,
'id' => $item->id,
+ 'shopifyGid' => $item->shopifyGid,
'shopifyId' => $item->shopifyId,
'type' => $item->type,
'parentId' => $item->parentId,
diff --git a/src/controllers/AuthController.php b/src/controllers/AuthController.php
index a0bb5fbc..1c1833af 100644
--- a/src/controllers/AuthController.php
+++ b/src/controllers/AuthController.php
@@ -58,7 +58,7 @@ public function actionIndex(): YiiResponse
$validHmac = Utils::validateHmac(Craft::$app->getRequest()->getQueryParams(), $settings->getClientSecret());
if (!$validHmac) {
$html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), Craft::t('shopify', 'Invalid or missing HMAC. Please try re-installing the app.'));
- return $this->_screenContent($screen, $html);
+ return $screen->contentHtml($html);
}
// If a code is present, it means the user has been redirected back from Shopify after authorizing the app.
@@ -93,13 +93,13 @@ public function actionIndex(): YiiResponse
Html::endTag('div')
;
- return $this->_screenContent($screen, $html);
+ return $screen->contentHtml($html);
} catch (\Exception $e) {
Craft::error($e->getMessage(), __METHOD__);
$html = $this->_errorHtml(Craft::t('shopify', 'Error authorizing app'), $e->getMessage());
- return $this->_screenContent($screen, $html);
+ return $screen->contentHtml($html);
}
}
@@ -118,18 +118,9 @@ public function actionIndex(): YiiResponse
Html::endTag('div') .
Html::endTag('div');
- return $this->_screenContent($screen, $html);
+ return $screen->contentHtml($html);
}
- /**
- * Render CP screen content across Craft 4/5.
- * @TODO remove when the plugin no longer supports Craft 4
- */
- private function _screenContent(Response $screen, string $html): YiiResponse
- {
- $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml';
- return $screen->{$method}($html);
- }
/**
* @param array $cookies
diff --git a/src/controllers/SettingsController.php b/src/controllers/SettingsController.php
index 59e2d59e..969b214c 100644
--- a/src/controllers/SettingsController.php
+++ b/src/controllers/SettingsController.php
@@ -16,7 +16,6 @@
use craft\shopify\models\Settings;
use craft\shopify\Plugin;
use craft\web\Controller;
-use craft\web\Response as CraftResponse;
use yii\web\Response;
/**
@@ -66,6 +65,16 @@ public function actionIndex(?Settings $settings = null): Response
'warning' => !Plugin::getInstance()->getApi()->getSession() ? Craft::t('shopify', 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.') : null,
];
+ $scopesFieldConfig = [
+ 'label' => $settings->getAttributeLabel('scopes'),
+ 'instructions' => Craft::t('shopify', 'API scopes required for your app integration, including additional features and custom scopes.'),
+ 'id' => 'scopes',
+ 'name' => 'settings[scopes]',
+ 'value' => $settings->getScopes(),
+ 'readonly' => true,
+ 'tip' => Craft::t('shopify', 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.'),
+ ];
+
$html = Html::beginTag('div', ['id' => 'products', 'class' => 'hidden']) .
// Products tab has to go first because the routing table overrides the `settings` key
Cp::editableTableFieldHtml([
@@ -173,6 +182,36 @@ public function actionIndex(?Settings $settings = null): Response
Html::tag('hr') .
+ Html::beginTag('div', ['id' => 'scopes-settings']) .
+
+ Cp::fieldHtml(
+ Cp::renderTemplate('_includes/forms/copytext.twig', $scopesFieldConfig),
+ $scopesFieldConfig
+ ) .
+
+ Cp::checkboxSelectFieldHtml([
+ 'id' => 'additionalFeatures',
+ 'label' => $settings->getAttributeLabel('additionalFeatures'),
+ 'name' => 'settings[additionalFeatures]',
+ 'options' => $settings->getAdditionalFeaturesOptions(),
+ 'values' => $settings->getAdditionalFeatures(),
+ 'showAllOption' => true,
+ ]) .
+
+ Cp::autosuggestFieldHtml([
+ 'label' => $settings->getAttributeLabel('customScopes'),
+ 'instructions' => Craft::t('shopify', 'A comma separated list of custom scopes to add to the API requests.'),
+ 'id' => 'customScopes',
+ 'name' => 'settings[customScopes]',
+ 'value' => $settings->getCustomScopes(false),
+ 'errors' => $settings->getErrors('customScopes'),
+ 'suggestEnvVars' => true,
+ ]) .
+
+ Html::endTag('div') .
+
+ Html::tag('hr') .
+
Cp::fieldHtml(
Cp::renderTemplate('_includes/forms/copytext.twig', $authUrlFieldConfig),
$authUrlFieldConfig
@@ -181,6 +220,56 @@ public function actionIndex(?Settings $settings = null): Response
Html::endTag('div')
;
+ $getScopesAction = 'shopify/settings/get-scopes';
+ $js = << {
+ const scopesSettingsContainer = document.getElementById('scopes-settings');
+ if (!scopesSettingsContainer) return;
+
+ let debounceTimer;
+
+ const updateScopes = () => {
+ const additionalFeatures = Array.from(
+ scopesSettingsContainer.querySelectorAll('input[name="settings[additionalFeatures][]"]:checked')
+ ).map(cb => cb.value).filter(Boolean);
+
+ const customScopesInput = document.getElementById('customScopes');
+ const scopesInput = document.getElementById('scopes');
+ if (!scopesInput) return;
+
+ Craft.sendActionRequest('POST', '$getScopesAction', {
+ data: {
+ additionalFeatures,
+ customScopes: customScopesInput?.value ?? '',
+ },
+ }).then(response => {
+ scopesInput.value = response.data.scopes;
+ }).catch(() => {
+ Craft.cp.displayError(Craft.t('shopify', 'Couldn’t update scopes.'));
+ });
+ };
+
+ scopesSettingsContainer.addEventListener('change', (e) => {
+ if (e.target.name === 'settings[additionalFeatures][]' || e.target.name === 'settings[additionalFeatures]') {
+ // Defer so Craft's checkbox-select JS can toggle related checkboxes first
+ setTimeout(updateScopes, 0);
+ }
+ });
+
+ scopesSettingsContainer.addEventListener('input', (e) => {
+ if (e.target.id === 'customScopes') {
+ clearTimeout(debounceTimer);
+ debounceTimer = setTimeout(updateScopes, 300);
+ }
+ });
+ })();
+ JS;
+ $this->getView()->registerJs($js);
+
+ $this->getView()->registerTranslations('shopify', [
+ 'Couldn’t update scopes.',
+ ]);
+
$screen = $this->asCpScreen()
->title(Craft::t('shopify', 'Settings'))
->tabs([
@@ -193,23 +282,31 @@ public function actionIndex(?Settings $settings = null): Response
$screen->action('shopify/settings/save-settings')
->redirectUrl('shopify/settings');
} else {
- // @TODO remove when the plugin no longer support Craft 4
- if ($screen->hasMethod('noticeHtml') && method_exists(Cp::class, 'readOnlyNoticeHtml')) {
- $screen->noticeHtml(Cp::readOnlyNoticeHtml());
- }
+ $screen->noticeHtml(Cp::readOnlyNoticeHtml());
}
- return $this->_screenContent($screen, $html);
+ return $screen->contentHtml($html);
}
/**
- * Render CP screen content across Craft 4/5.
- * @TODO remove when the plugin no longer supports Craft 4
+ * Returns the combined scopes string for the given additional features and custom scopes.
+ *
+ * @return Response
+ * @since 7.2.0
*/
- private function _screenContent(CraftResponse $screen, string $html): Response
+ public function actionGetScopes(): Response
{
- $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml';
- return $screen->{$method}($html);
+ $this->requireAcceptsJson();
+ $this->requirePostRequest();
+
+ $request = Craft::$app->getRequest();
+ $settings = new Settings();
+ $settings->setAdditionalFeatures((array)$request->getBodyParam('additionalFeatures', []));
+ $settings->setCustomScopes($request->getBodyParam('customScopes', ''));
+
+ return $this->asJson([
+ 'scopes' => $settings->getScopes(),
+ ]);
}
/**
@@ -226,6 +323,13 @@ public function actionSaveSettings(): ?Response
$pluginSettings = $plugin->getSettings();
$originalUriFormat = $pluginSettings->uriFormat;
+ $settings['additionalFeatures'] = $settings['additionalFeatures'] ?: [];
+
+ // Expand the checkboxSelect "All" wildcard to the full list of feature handles
+ if ($settings['additionalFeatures'] === '*') {
+ $settings['additionalFeatures'] = array_keys($pluginSettings->getAdditionalFeaturesOptions());
+ }
+
// Remove from editable table namespace
$settings['uriFormat'] = $settings['routing']['uriFormat'];
// Could be blank if in headless mode
diff --git a/src/controllers/WebhooksController.php b/src/controllers/WebhooksController.php
index f7bb5df0..d29b1c0a 100644
--- a/src/controllers/WebhooksController.php
+++ b/src/controllers/WebhooksController.php
@@ -11,7 +11,6 @@
use craft\helpers\Html;
use craft\shopify\Plugin;
use craft\web\Controller;
-use craft\web\Response as CraftResponse;
use GraphQL\Query;
use GraphQL\Variable;
use Shopify\Exception\ShopifyException;
@@ -164,17 +163,7 @@ public function actionEdit(): YiiResponse
->title(Craft::t('shopify', 'Webhooks'))
->selectedSubnavItem('webhooks');
- return $this->_screenContent($screen, $html);
- }
-
- /**
- * Render CP screen content across Craft 4/5
- * @TODO remove when the plugin no longer supports Craft 4
- */
- private function _screenContent(CraftResponse $screen, string $html): YiiResponse
- {
- $method = !$screen->hasMethod('contentHtml') ? 'content' : 'contentHtml';
- return $screen->{$method}($html);
+ return $screen->contentHtml($html);
}
/**
diff --git a/src/elements/Product.php b/src/elements/Product.php
index 6c155700..7a467c20 100644
--- a/src/elements/Product.php
+++ b/src/elements/Product.php
@@ -23,6 +23,7 @@
use craft\shopify\fieldlayoutelements\MetafieldsField;
use craft\shopify\fieldlayoutelements\OptionsField;
use craft\shopify\fieldlayoutelements\VariantsField;
+use craft\shopify\helpers\Metafield as MetafieldHelper;
use craft\shopify\helpers\Product as ProductHelper;
use craft\shopify\models\Variant;
use craft\shopify\Plugin;
@@ -359,8 +360,9 @@ public function getOptions(): array
}
/**
- * @param string|array $value
+ * @param string|array $value A list-shaped array of `{key, value}` objects, or a JSON-encoded string of the same.
* @return void
+ * @throws \InvalidArgumentException if the value is not a list-shaped array or JSON string of one.
*/
public function setMetafields(string|array $value): void
{
@@ -368,7 +370,11 @@ public function setMetafields(string|array $value): void
$value = Json::decodeIfJson($value);
}
- $this->_metaFields = $value;
+ if (!is_array($value) || !array_is_list($value)) {
+ throw new \InvalidArgumentException('setMetafields() expects a list-shaped array of {key, value} objects or a JSON-encoded string of the same.');
+ }
+
+ $this->_metaFields = MetafieldHelper::normalizeToMap($value);
}
/**
@@ -387,14 +393,7 @@ public function getMetafields(): array
$data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid);
- $metafields = $data
- ->mapWithKeys(function($d) {
- return [
- $d['key'] => Json::decodeIfJson($d['value']),
- ];
- });
-
- $this->setMetafields($metafields);
+ $this->setMetafields($data->all());
return $this->_metaFields ?? [];
}
@@ -731,7 +730,7 @@ public function getSidebarHtml(bool $static): string
// Conditionally show metadata in the sidebar dependent on the field layout
$excludeKeys = [];
$fieldLayout = $this->getFieldLayout();
- $checkField = function($field) use (&$excludeKeys) {
+ $fieldLayout->getFields(function($field) use (&$excludeKeys) {
if ($field instanceof VariantsField) {
$excludeKeys[] = 'Variants';
return true;
@@ -743,16 +742,7 @@ public function getSidebarHtml(bool $static): string
return true;
}
return false;
- };
-
- // @TODO remove when the plugin no longer supports Craft 4
- if (!method_exists($fieldLayout, 'getFields')) {
- foreach ($fieldLayout->getCustomFields() as $field) {
- $checkField($field);
- }
- } else {
- $fieldLayout->getFields($checkField);
- }
+ });
return ProductHelper::renderCardHtml($this, $excludeKeys) . parent::getSidebarHtml($static);
}
@@ -823,7 +813,7 @@ public function afterDelete(): void
{
// Remove all the product shopify data
if ($this->shopifyGid && $this->getIsCanonical()) {
- Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->shopifyGid);
+ Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->shopifyGid);
}
parent::afterDelete();
@@ -892,29 +882,6 @@ protected static function defineSortOptions(): array
return $sortOptions;
}
- /**
- * @param string $attribute
- * @return string
- * @throws InvalidConfigException
- * @TODO remove this method when support for Craft 4 is dropped
- */
- protected function tableAttributeHtml(string $attribute): string
- {
- if (!in_array($attribute, [
- 'shopifyEdit',
- 'shopifyStatus',
- 'shopifyId',
- 'options',
- 'tags',
- 'variants',
- ])) {
- /** @phpstan-ignore-next-line */
- return parent::tableAttributeHtml($attribute);
- }
-
- return $this->attributeHtml($attribute);
- }
-
/**
* @param string $attribute
* @return string
diff --git a/src/elements/conditions/products/ProductCondition.php b/src/elements/conditions/products/ProductCondition.php
index e1f00696..5144d412 100644
--- a/src/elements/conditions/products/ProductCondition.php
+++ b/src/elements/conditions/products/ProductCondition.php
@@ -17,21 +17,6 @@
*/
class ProductCondition extends ElementCondition
{
- /**
- * @inheritdoc
- * @TODO remove this method when support for Craft 4 is dropped
- */
- protected function conditionRuleTypes(): array
- {
- return array_merge(parent::conditionRuleTypes(), [
- ProductTypeConditionRule::class,
- ShopifyStatusConditionRule::class,
- VendorConditionRule::class,
- HandleConditionRule::class,
- TagsConditionRule::class,
- ]);
- }
-
/**
* @inheritdoc
*/
diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php
index 66fc57bb..8f295c6c 100644
--- a/src/elements/db/ProductQuery.php
+++ b/src/elements/db/ProductQuery.php
@@ -396,8 +396,8 @@ protected function beforePrepare(): bool
// join standard product element table that only contains the shopifyId
$this->joinElementTable('shopify_products');
- $this->query->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyId]] = [[shopify_products.shopifyGid]]'));
- $this->subQuery->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyId]] = [[shopify_products.shopifyGid]]'));
+ $this->query->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyGid]] = [[shopify_products.shopifyGid]]'));
+ $this->subQuery->innerJoin(Table::DATA . ' data', new Expression('[[data.shopifyGid]] = [[shopify_products.shopifyGid]]'));
$this->query->select([
'shopify_products.shopifyId',
diff --git a/src/enums/BulkOperationStatus.php b/src/enums/BulkOperationStatus.php
index bfdc3540..f501dc2e 100644
--- a/src/enums/BulkOperationStatus.php
+++ b/src/enums/BulkOperationStatus.php
@@ -42,17 +42,6 @@ public function statusAsLabel(): string
*/
public function statusLabelHtml(): string
{
- // @TODO update this either when Craft 4 support is dropped or 4 gets enums
- if (!class_exists(Color::class) || !method_exists(Cp::class, 'statusLabelHtml')) {
- $color = match ($this) {
- self::Created => 'blue',
- self::Processing => 'yellow',
- self::Completed => 'green',
- default => 'gray', // takes care of draft
- };
- return "" . $this->statusAsLabel();
- }
-
return Cp::statusLabelHtml([
'color' => match ($this) {
self::Queued => Color::Gray,
diff --git a/src/events/DefineContextConfigEvent.php b/src/events/DefineContextConfigEvent.php
new file mode 100644
index 00000000..b3e6d311
--- /dev/null
+++ b/src/events/DefineContextConfigEvent.php
@@ -0,0 +1,24 @@
+
+ * @since 7.2.0
+ */
+class DefineContextConfigEvent extends Event
+{
+ /**
+ * @var array Array of the arguments used to initialize the API context (`Context::initialize()`).
+ */
+ public array $config = [];
+}
diff --git a/src/fieldlayoutelements/MetafieldsField.php b/src/fieldlayoutelements/MetafieldsField.php
index ade31b9c..d39c5e6a 100644
--- a/src/fieldlayoutelements/MetafieldsField.php
+++ b/src/fieldlayoutelements/MetafieldsField.php
@@ -74,30 +74,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa
];
}
- // @TODO remove this when Craft 4 support is dropped
- $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute();
-
- $tableConfig = [
+ return Cp::editableTableHtml([
'id' => $this->id(),
- 'name' => $name,
+ 'name' => $this->baseInputName(),
'cols' => $cols,
'rows' => $tableData,
'static' => true,
- ];
-
- return $this->_editableTableHtml($tableConfig);
- }
-
- /**
- * @TODO remove this when Craft 4 support is dropped
- */
- private function _editableTableHtml(array $tableConfig): string
- {
- // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4)
- if (!is_callable([Cp::class, 'editableTableHtml'])) {
- return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig);
- }
-
- return Cp::editableTableHtml($tableConfig);
+ ]);
}
}
diff --git a/src/fieldlayoutelements/OptionsField.php b/src/fieldlayoutelements/OptionsField.php
index 20b1a6cb..4640bc11 100644
--- a/src/fieldlayoutelements/OptionsField.php
+++ b/src/fieldlayoutelements/OptionsField.php
@@ -85,30 +85,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa
}
}
- // @TODO remove this when Craft 4 support is dropped
- $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute();
-
- $tableConfig = [
+ return Cp::editableTableHtml([
'id' => $this->id(),
- 'name' => $name,
+ 'name' => $this->baseInputName(),
'cols' => $cols,
'rows' => $tableData,
'static' => true,
- ];
-
- return $this->_editableTableHtml($tableConfig);
- }
-
- /**
- * @TODO remove this when Craft 4 support is dropped
- */
- private function _editableTableHtml(array $tableConfig): string
- {
- // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4)
- if (!is_callable([Cp::class, 'editableTableHtml'])) {
- return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig);
- }
-
- return Cp::editableTableHtml($tableConfig);
+ ]);
}
}
diff --git a/src/fieldlayoutelements/VariantsField.php b/src/fieldlayoutelements/VariantsField.php
index cab9e0e1..e38c5147 100644
--- a/src/fieldlayoutelements/VariantsField.php
+++ b/src/fieldlayoutelements/VariantsField.php
@@ -63,7 +63,7 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa
];
foreach ($variants as $variant) {
- $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), str_replace('gid://shopify/ProductVariant/', '', $variant->shopifyId));
+ $link = sprintf('%s/variants/%s', $element->getShopifyEditUrl(), $variant->shopifyId);
$title = $variant->title;
$sku = $variant->sku;
@@ -86,30 +86,12 @@ protected function inputHtml(ElementInterface $element = null, bool $static = fa
Html::endTag('div');
}
- // @TODO remove this when Craft 4 support is dropped
- $name = method_exists($this, 'baseInputName') ? $this->baseInputName() : $this->attribute();
-
- $tableConfig = [
+ return Cp::editableTableHtml([
'id' => $this->id(),
- 'name' => $name,
+ 'name' => $this->baseInputName(),
'cols' => $cols,
'rows' => $variantRows,
'static' => true,
- ];
-
- return $this->_editableTableHtml($tableConfig);
- }
-
- /**
- * @TODO remove this when Craft 4 support is dropped
- */
- private function _editableTableHtml(array $tableConfig): string
- {
- // @phpstan-ignore booleanNot.alwaysFalse (Craft 4 compatibility: method does not exist in Craft 4)
- if (!is_callable([Cp::class, 'editableTableHtml'])) {
- return Cp::renderTemplate('_includes/forms/editableTable.twig', $tableConfig);
- }
-
- return Cp::editableTableHtml($tableConfig);
+ ]);
}
}
diff --git a/src/gql/types/Variant.php b/src/gql/types/Variant.php
index b80276cc..96579ceb 100644
--- a/src/gql/types/Variant.php
+++ b/src/gql/types/Variant.php
@@ -56,13 +56,11 @@ public static function getFieldDefinitions(): array
'name' => 'shopifyId',
'type' => Type::string(),
'description' => 'Shopify ID of the variant.',
- 'resolve' => fn(VariantElement $source) => str_replace('gid://shopify/ProductVariant/', '', $source->shopifyId),
],
'shopifyGid' => [
'name' => 'shopifyGid',
'type' => Type::string(),
'description' => 'Shopify GID of the variant.',
- 'resolve' => fn(VariantElement $source) => $source->shopifyId,
],
'title' => [
'name' => 'title',
diff --git a/src/handlers/Webhook.php b/src/handlers/Webhook.php
index 1b31c923..700944d8 100644
--- a/src/handlers/Webhook.php
+++ b/src/handlers/Webhook.php
@@ -24,10 +24,10 @@ public function handle(string $topic, string $shop, array $body): void
switch ($topic) {
case Topics::PRODUCTS_UPDATE:
case Topics::PRODUCTS_CREATE:
- Plugin::getInstance()->getProducts()->syncProductByShopifyId($body['id']);
+ Plugin::getInstance()->getProducts()->syncProductByShopifyGid($body['id']);
break;
case Topics::PRODUCTS_DELETE:
- Plugin::getInstance()->getProducts()->deleteProductByShopifyId($body['id']);
+ Plugin::getInstance()->getProducts()->deleteProductByShopifyGid($body['id']);
break;
case Topics::INVENTORY_ITEMS_UPDATE:
Plugin::getInstance()->getProducts()->syncProductByInventoryItemId($body['admin_graphql_api_id']);
diff --git a/src/helpers/Metafield.php b/src/helpers/Metafield.php
new file mode 100644
index 00000000..ba243427
--- /dev/null
+++ b/src/helpers/Metafield.php
@@ -0,0 +1,53 @@
+
+ * @since 8.0.0
+ */
+class Metafield
+{
+ /**
+ * Normalizes an iterable of metafield data into a flat key => value map.
+ *
+ * Accepts either:
+ * - [[ShopifyData]] ActiveRecord objects (from [[Api::getShopifyDataByType()]] with `$returnRecords = true`),
+ * where each record's `data` column holds a JSON-encoded `{key, value}` object.
+ * - Pre-decoded associative arrays (from [[Api::getShopifyDataByType()]] without `$returnRecords`),
+ * where each item already has `key` and `value` keys.
+ *
+ * Rows that do not carry both `key` and `value` are silently skipped.
+ *
+ * @param iterable $rows
+ * @return array
+ */
+ public static function normalizeToMap(iterable $rows): array
+ {
+ return collect($rows)
+ ->mapWithKeys(function($d) {
+ $data = match (true) {
+ $d instanceof ShopifyData => Json::decodeIfJson($d->data),
+ is_string($d) => Json::decodeIfJson($d),
+ default => $d,
+ };
+
+ if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) {
+ return [];
+ }
+
+ return [$data['key'] => Json::decodeIfJson($data['value'])];
+ })
+ ->all();
+ }
+}
diff --git a/src/helpers/Product.php b/src/helpers/Product.php
index abef6ce9..eb4ac17f 100644
--- a/src/helpers/Product.php
+++ b/src/helpers/Product.php
@@ -131,7 +131,7 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet
// This is the date updated in the database which represents the last time it was updated from a Shopify webhook or sync.
/** @var ShopifyData $productData */
- $productData = ShopifyData::find()->where(['shopifyId' => $product->shopifyGid])->one();
+ $productData = ShopifyData::find()->where(['shopifyGid' => $product->shopifyGid])->one();
$dateUpdated = DateTimeHelper::toDateTime($productData->dateUpdated);
$now = new \DateTime();
$diff = $now->diff($dateUpdated);
@@ -153,16 +153,6 @@ public static function renderCardHtml(ProductElement $product, array $excludeMet
*/
public static function shopifyStatusHtml(ProductElement $product): string
{
- // @TODO update this either when Craft 4 support is dropped or 4 gets enums
- if (!class_exists(Color::class) || !method_exists(Cp::class, 'statusLabelHtml')) {
- $color = match (StringHelper::toLowerCase($product->shopifyStatus)) {
- ProductElement::SHOPIFY_STATUS_ACTIVE => 'green',
- ProductElement::SHOPIFY_STATUS_ARCHIVED => 'red',
- default => 'orange', // takes care of draft
- };
- return "" . StringHelper::titleize($product->shopifyStatus);
- }
-
$color = match (StringHelper::toLowerCase($product->shopifyStatus)) {
ProductElement::SHOPIFY_STATUS_ACTIVE => Color::Green->value,
ProductElement::SHOPIFY_STATUS_ARCHIVED => Color::Red->value,
diff --git a/src/jobs/ProcessBulkOperationData.php b/src/jobs/ProcessBulkOperationData.php
index 0799c122..159d2fe9 100644
--- a/src/jobs/ProcessBulkOperationData.php
+++ b/src/jobs/ProcessBulkOperationData.php
@@ -23,7 +23,7 @@ class ProcessBulkOperationData extends BaseBatchedJob
/**
* @var string
*/
- public string $bulkOperationShopifyId;
+ public string $bulkOperationShopifyGid = '';
/**
* @var string
@@ -110,7 +110,7 @@ protected function processItem(mixed $item): void
// Find data records based on their Shopify ID and parent ID. This is to avoid overwriting
// records with the same ID but different parents (e.g. Metafields, Images etc).
- $record = ShopifyData::findOne(['shopifyId' => $item['id'], 'parentId' => $parentId]);
+ $record = ShopifyData::findOne(['shopifyGid' => $item['id'], 'parentId' => $parentId]);
if (!$record) {
$record = new ShopifyData();
}
@@ -119,7 +119,7 @@ protected function processItem(mixed $item): void
$parts = explode('/', str_replace('gid://shopify/', '', $item['id']));
$type = $parts[0];
- $record->shopifyId = $item['id'];
+ $record->shopifyGid = $item['id'];
$record->type = $type;
$record->data = $item;
$record->parentId = $item['__parentId'] ?? null;
@@ -139,7 +139,7 @@ protected function before(): void
parent::before();
// Make sure bulk op is marked as processing
- $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId);
+ $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid);
if (!$bulkOperation) {
return;
@@ -153,7 +153,7 @@ protected function before(): void
if ($this->clearData === BulkOperationRecord::CLEAR_DATA_ALL) {
ShopifyData::deleteAll();
} elseif ($this->clearData !== BulkOperationRecord::CLEAR_DATA_NONE) {
- Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->clearData);
+ Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->clearData);
}
}
@@ -165,7 +165,7 @@ protected function after(): void
parent::after();
// Mark bulk op as completed
- $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId);
+ $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid);
if (!$bulkOperation) {
return;
diff --git a/src/migrations/Install.php b/src/migrations/Install.php
index fffe0c93..66ccf78b 100644
--- a/src/migrations/Install.php
+++ b/src/migrations/Install.php
@@ -57,7 +57,7 @@ public function createTables(): void
$this->archiveTableIfExists(Table::DATA);
$this->createTable(Table::DATA, [
'id' => $this->primaryKey(),
- 'shopifyId' => $this->string(),
+ 'shopifyGid' => $this->string(),
'type' => $this->string(),
'data' => $this->json(),
'parentId' => $this->string(),
@@ -69,7 +69,7 @@ public function createTables(): void
$this->archiveTableIfExists(Table::BULK_OPERATIONS);
$this->createTable(Table::BULK_OPERATIONS, [
'id' => $this->primaryKey(),
- 'shopifyId' => $this->string(),
+ 'shopifyGid' => $this->string(),
'url' => $this->text(),
'objectCount' => $this->integer(),
'query' => $this->text(),
@@ -139,6 +139,17 @@ public function createGeneratedColumns(): void
$db->quoteColumnName($alias) . ' ' . $qb->getColumnType($this->integer()) . " GENERATED ALWAYS AS (" .
$expression . ") STORED;");
}
+
+ // Derived shopifyId — the numeric ID at the end of the GID (e.g. "7136060145715" from "gid://shopify/Product/7136060145715")
+ if ($db->getIsPgsql()) {
+ $shopifyIdExpression = "regexp_replace(\"shopifyGid\", '^.*/', '')";
+ } else {
+ $shopifyIdExpression = "SUBSTRING_INDEX(`shopifyGid`, '/', -1)";
+ }
+
+ $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " .
+ $db->quoteColumnName('shopifyId') . ' ' . $qb->getColumnType($this->string()) . " GENERATED ALWAYS AS (" .
+ $shopifyIdExpression . ") STORED;");
}
/**
@@ -148,7 +159,7 @@ public function createIndexes(): void
{
$this->createIndex(null, Table::PRODUCTS, ['shopifyId'], false);
$this->createIndex(null, Table::PRODUCTS, ['shopifyGid'], false);
- $this->createIndex(null, Table::DATA, ['shopifyId'], false);
+ $this->createIndex(null, Table::DATA, ['shopifyGid'], false);
$this->createIndex(null, Table::DATA, ['parentId'], false);
}
diff --git a/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php b/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php
new file mode 100644
index 00000000..3ba15894
--- /dev/null
+++ b/src/migrations/m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table.php
@@ -0,0 +1,56 @@
+db->columnExists(Table::DATA, 'shopifyGid')) {
+ return true;
+ }
+
+ $db = $this->getDb();
+ $qb = $db->getQueryBuilder();
+
+ // Drop the existing index on shopifyId before renaming
+ $this->dropIndexIfExists(Table::DATA, ['shopifyId'], false);
+
+ // Rename shopifyId → shopifyGid
+ $this->renameColumn(Table::DATA, 'shopifyId', 'shopifyGid');
+
+ // Recreate the index on the renamed column
+ $this->createIndex(null, Table::DATA, ['shopifyGid'], false);
+
+ // Add generated shopifyId column — the numeric ID at the end of the GID
+ if ($db->getIsPgsql()) {
+ $expression = "regexp_replace(\"shopifyGid\", '^.*/', '')";
+ } else {
+ $expression = "SUBSTRING_INDEX(`shopifyGid`, '/', -1)";
+ }
+
+ $this->execute("ALTER TABLE " . Table::DATA . " ADD COLUMN " .
+ $db->quoteColumnName('shopifyId') . ' ' . $qb->getColumnType($this->string()) . " GENERATED ALWAYS AS (" .
+ $expression . ") STORED;");
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function safeDown(): bool
+ {
+ echo "m260617_100000_rename_shopifyId_to_shopifyGid_in_data_table cannot be reverted.\n";
+ return false;
+ }
+}
diff --git a/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php b/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php
new file mode 100644
index 00000000..72b243f5
--- /dev/null
+++ b/src/migrations/m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table.php
@@ -0,0 +1,35 @@
+db->columnExists(Table::BULK_OPERATIONS, 'shopifyGid')) {
+ return true;
+ }
+
+ $this->renameColumn(Table::BULK_OPERATIONS, 'shopifyId', 'shopifyGid');
+
+ return true;
+ }
+
+ /**
+ * @inheritdoc
+ */
+ public function safeDown(): bool
+ {
+ echo "m260617_100001_rename_shopifyId_to_shopifyGid_in_bulk_operations_table cannot be reverted.\n";
+ return false;
+ }
+}
diff --git a/src/models/BulkOperation.php b/src/models/BulkOperation.php
index 0e41d947..3bf4c1ca 100644
--- a/src/models/BulkOperation.php
+++ b/src/models/BulkOperation.php
@@ -26,9 +26,9 @@ class BulkOperation extends Model
public ?int $id = null;
/**
- * @var string|null The Shopify ID of the bulk operation.
+ * @var string|null The Shopify GID of the bulk operation (e.g. "gid://shopify/BulkOperation/123456789").
*/
- public ?string $shopifyId = null;
+ public ?string $shopifyGid = null;
/**
* @var string|null The URL of the bulk operation data.
@@ -80,8 +80,8 @@ protected function defineRules(): array
$rules = parent::defineRules();
$rules[] = [['id', 'objectCount'], 'number', 'integerOnly' => true];
- $rules[] = [['shopifyId', 'url'], 'string'];
- $rules[] = [['id', 'shopifyId', 'url', 'objectCount', 'status', 'shopifyStatus', 'query', 'dateCreated', 'dateUpdated'], 'safe'];
+ $rules[] = [['shopifyGid', 'url'], 'string'];
+ $rules[] = [['id', 'shopifyGid', 'url', 'objectCount', 'status', 'shopifyStatus', 'query', 'dateCreated', 'dateUpdated'], 'safe'];
return $rules;
}
diff --git a/src/models/Settings.php b/src/models/Settings.php
index ae892a54..73544c75 100644
--- a/src/models/Settings.php
+++ b/src/models/Settings.php
@@ -32,10 +32,14 @@ class Settings extends Model
private string $_accessToken = '';
private string $_hostName = '';
+ private array $_additionalFeatures = [];
+ private string $_customScopes = '';
public string $uriFormat = '';
public string $template = '';
private mixed $_productFieldLayout;
+ public const REQUIRED_SCOPES = ['read_inventory', 'read_product_listings', 'read_products'];
+
/**
* @var string|null Comma separated list of country codes to use for contextual pricing.
*/
@@ -53,6 +57,8 @@ public function rules(): array
return [
[['clientSecret', 'clientId', 'hostName', 'apiVersion'], 'required'],
[['apiVersion'], 'in', 'range' => Plugin::getInstance()->getApi()->getSupportedApiVersions()],
+ [['additionalFeatures'], 'in', 'range' => array_keys($this->getAdditionalFeaturesOptions()), 'allowArray' => true],
+ [['customScopes'], 'string', 'skipOnEmpty' => true],
[['hostName'], function($attribute) {
$hostName = $this->$attribute;
@@ -66,10 +72,12 @@ public function rules(): array
public function attributes()
{
$names = parent::attributes();
+ $names[] = 'additionalFeatures';
$names[] = 'apiVersion';
$names[] = 'clientId';
$names[] = 'clientSecret';
$names[] = 'contextualPricingCountries';
+ $names[] = 'customScopes';
$names[] = 'hostName';
$names[] = 'uriFormat';
$names[] = 'template';
@@ -80,10 +88,12 @@ public function attributes()
public function fields(): array
{
return [
+ 'additionalFeatures' => fn() => $this->getAdditionalFeatures(),
'apiVersion' => fn() => $this->getApiVersion(false),
'clientId' => fn() => $this->getClientId(false),
'clientSecret' => fn() => $this->getClientSecret(false),
'contextualPricingCountries' => fn() => $this->getContextualPricingCountries(false),
+ 'customScopes' => fn() => $this->getCustomScopes(false),
'hostName' => fn() => $this->getHostName(false),
'uriFormat' => 'uriFormat',
'template' => 'template',
@@ -96,14 +106,17 @@ public function fields(): array
public function attributeLabels(): array
{
return [
+ 'additionalFeatures' => Craft::t('app', 'Additional Features'),
+ 'apiVersion' => Craft::t('shopify', 'Shopify API Version'),
'authUrl' => Craft::t('shopify', 'Shopify App Auth URL'),
'clientId' => Craft::t('shopify', 'Shopify Client ID'),
'clientSecret' => Craft::t('shopify', 'Shopify Client Secret Key'),
- 'apiVersion' => Craft::t('shopify', 'Shopify API Version'),
'contextualPricingCountries' => Craft::t('shopify', 'Context Pricing Countries'),
+ 'customScopes' => Craft::t('shopify', 'Custom Scopes'),
'hostName' => Craft::t('shopify', 'Shopify Host Name'),
- 'uriFormat' => Craft::t('shopify', 'Product URI format'),
+ 'scopes' => Craft::t('shopify', 'Scopes'),
'template' => Craft::t('shopify', 'Product Template'),
+ 'uriFormat' => Craft::t('shopify', 'Product URI format'),
];
}
@@ -215,7 +228,6 @@ public function getClientSecret(bool $parse = true): string
return ($parse ? App::parseEnv($this->_clientSecret) : $this->_clientSecret) ?? '';
}
-
/**
* @param string $hostName
* @return void
@@ -236,6 +248,118 @@ public function getHostName(bool $parse = true): string
return ($parse ? App::parseEnv($this->_hostName) : $this->_hostName) ?? '';
}
+ /**
+ * @param array $additionalFeatures
+ * @return void
+ * @since 7.2.0
+ */
+ public function setAdditionalFeatures(array $additionalFeatures): void
+ {
+ $this->_additionalFeatures = $additionalFeatures;
+ }
+
+ /**
+ * @return array
+ * @since 7.2.0
+ */
+ public function getAdditionalFeatures(bool $asScopes = false): array
+ {
+ if ($asScopes && !empty($this->_additionalFeatures)) {
+ $scopes = [];
+ foreach ($this->_additionalFeatures as $additionalFeature) {
+ $adFeat = $this->getAdditionalFeaturesOptions()[$additionalFeature] ?? null;
+ if ($adFeat) {
+ $scopes[] = $adFeat['scope'];
+ }
+ }
+
+ return $scopes;
+ }
+
+ return $this->_additionalFeatures;
+ }
+
+ /**
+ * @return array
+ * @since 7.2.0
+ */
+ public function getAdditionalFeaturesOptions(): array
+ {
+ return [
+ 'productTranslations' => [
+ 'label' => Craft::t('shopify', 'Product Translations'),
+ 'value' => 'productTranslations',
+ 'scope' => 'read_locales',
+ ],
+ ];
+ }
+
+
+ /**
+ * @param string $additionalScopes
+ * @return void
+ * @since 7.2.0
+ */
+ public function setCustomScopes(string $additionalScopes): void
+ {
+ // Preserve env var references as-is; normalize plain-text values
+ if (!str_starts_with($additionalScopes, '$')) {
+ $additionalScopes = implode(',', array_filter(array_map(
+ fn($s) => self::_normalizeScope($s),
+ explode(',', $additionalScopes)
+ )));
+ }
+
+ $this->_customScopes = $additionalScopes;
+ }
+
+ /**
+ * @param bool $parse
+ * @return string
+ * @since 7.2.0
+ */
+ public function getCustomScopes(bool $parse = true): string
+ {
+ return ($parse ? App::parseEnv($this->_customScopes) : $this->_customScopes) ?? '';
+ }
+
+ /**
+ * @param bool $asArray
+ * @return array|string
+ * @since 7.2.0
+ */
+ public function getScopes(bool $asArray = false): array|string
+ {
+ $scopes = array_merge(self::REQUIRED_SCOPES, $this->getAdditionalFeatures(true));
+
+ $customScopes = $this->getCustomScopes();
+ if ($customScopes) {
+ $scopes = array_merge($scopes, array_filter(array_map(
+ fn($s) => self::_normalizeScope($s),
+ explode(',', $customScopes)
+ )));
+ }
+
+ $scopes = array_unique($scopes);
+ asort($scopes);
+
+ if ($asArray) {
+ return $scopes;
+ }
+
+ return implode(',', $scopes);
+ }
+
+ /**
+ * Normalizes a single scope string: lowercases, trims whitespace, and returns an empty string if the result
+ * contains characters outside `[a-z0-9_]`.
+ */
+ private static function _normalizeScope(string $scope): string
+ {
+ $normalized = strtolower(trim($scope));
+ return preg_match('/^[a-z0-9_]+$/', $normalized) ? $normalized : '';
+ }
+
/**
* @param string $accessToken
* @return void
diff --git a/src/models/Variant.php b/src/models/Variant.php
index 688e4990..f07470f4 100644
--- a/src/models/Variant.php
+++ b/src/models/Variant.php
@@ -9,6 +9,7 @@
use craft\base\Model;
use craft\helpers\Json;
+use craft\shopify\helpers\Metafield as MetafieldHelper;
use craft\shopify\Plugin;
use DateTime;
use yii\base\InvalidConfigException;
@@ -16,6 +17,7 @@
/**
* Variant model.
*
+ * @property-read string $shopifyGid
* @property-read string $shopifyId
* @property-read string $title
* @property-read string $sku
@@ -31,7 +33,12 @@ class Variant extends Model
public ?int $id = null;
/**
- * @var string|null The Shopify ID of the variant.
+ * @var string|null The Shopify GID of the variant (e.g. "gid://shopify/ProductVariant/123456789").
+ */
+ public ?string $shopifyGid = null;
+
+ /**
+ * @var string|null The numeric Shopify ID of the variant (last segment of the GID).
*/
public ?string $shopifyId = null;
@@ -103,7 +110,7 @@ protected function defineRules(): array
{
$rules = parent::defineRules();
- $rules[] = [['id', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe'];
+ $rules[] = [['id', 'shopifyGid', 'shopifyId', 'type', 'parentId', 'data', 'dateCreated', 'dateUpdated', 'uid'], 'safe'];
return $rules;
}
@@ -142,21 +149,21 @@ public function getData(): array
}
/**
- * @param string|array $value
+ * @param string|array $value A list-shaped array of `{key, value}` objects, or a JSON-encoded string of the same.
* @return void
+ * @throws \InvalidArgumentException if the value is not a list-shaped array or JSON string of one.
*/
public function setMetafields(string|array $value): void
{
if (is_string($value)) {
$value = Json::decodeIfJson($value);
- $value = collect($value)->mapWithKeys(function($d) {
- return [
- $d['key'] => Json::decodeIfJson($d['value']),
- ];
- });
}
- $this->_metaFields = $value;
+ if (!is_array($value) || !array_is_list($value)) {
+ throw new \InvalidArgumentException('setMetafields() expects a list-shaped array of {key, value} objects or a JSON-encoded string of the same.');
+ }
+
+ $this->_metaFields = MetafieldHelper::normalizeToMap($value);
}
/**
@@ -165,7 +172,7 @@ public function setMetafields(string|array $value): void
*/
public function getMetafields(): array
{
- if (!$this->shopifyId) {
+ if (!$this->shopifyGid) {
return [];
}
@@ -173,16 +180,9 @@ public function getMetafields(): array
return $this->_metaFields;
}
- $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyId);
-
- $metafields = $data
- ->mapWithKeys(function($d) {
- return [
- $d['key'] => Json::decodeIfJson($d['value']),
- ];
- });
+ $data = Plugin::getInstance()->getApi()->getShopifyDataByType('Metafield', $this->shopifyGid);
- $this->setMetafields($metafields->all());
+ $this->setMetafields($data->all());
return $this->_metaFields ?? [];
}
diff --git a/src/records/BulkOperation.php b/src/records/BulkOperation.php
index 258649c8..0061e04c 100644
--- a/src/records/BulkOperation.php
+++ b/src/records/BulkOperation.php
@@ -17,7 +17,7 @@
* @since 6.0.0
*
* @property int $id
- * @property string $shopifyId
+ * @property string $shopifyGid
* @property string $url
* @property string $status
* @property string $shopifyStatus
diff --git a/src/records/Product.php b/src/records/Product.php
index 36459106..46cc566a 100644
--- a/src/records/Product.php
+++ b/src/records/Product.php
@@ -39,6 +39,6 @@ public function getElement(): ActiveQueryInterface
public function getData(): ActiveQueryInterface
{
- return $this->hasOne(ShopifyData::class, ['shopifyGid' => 'shopifyId']);
+ return $this->hasOne(ShopifyData::class, ['shopifyGid' => 'shopifyGid']);
}
}
diff --git a/src/records/ShopifyData.php b/src/records/ShopifyData.php
index 59625b0f..75da9387 100644
--- a/src/records/ShopifyData.php
+++ b/src/records/ShopifyData.php
@@ -17,6 +17,7 @@
* @since 6.0.0
*
* @property int $id
+ * @property string $shopifyGid
* @property string $shopifyId
* @property string $type
* @property string|array $data
diff --git a/src/services/Api.php b/src/services/Api.php
index 89f4b415..f525b02c 100644
--- a/src/services/Api.php
+++ b/src/services/Api.php
@@ -13,6 +13,7 @@
use craft\helpers\Json;
use craft\helpers\StringHelper;
use craft\log\MonologTarget;
+use craft\shopify\events\DefineContextConfigEvent;
use craft\shopify\events\DefineGqlFieldsEvent;
use craft\shopify\events\DefineGqlQueryArgumentsEvent;
use craft\shopify\Plugin;
@@ -81,6 +82,18 @@ class Api extends Component
*/
public const EVENT_DEFINE_GQL_QUERY_ARGUMENTS = 'defineGqlQueryArguments';
+ /**
+ * @event DefineContextConfigEvent Trigged before initializing the Shopify API context, which is required for authentication and making API calls.
+ * @since 7.2.0
+ */
+ public const EVENT_DEFINE_CONTEXT_CONFIG = 'defineContextConfig';
+
+ /**
+ * @event Event Triggered after the Shopify API context has been initialized, which is required for authentication and making API calls.
+ * @since 7.2.0
+ */
+ public const EVENT_CONTEXT_INITIALIZED = 'contextInitialized';
+
/**
* @var Session|null
*/
@@ -149,6 +162,20 @@ public function getShopGql(): Query
return $this->createQuery('shop', $fields);
}
+ /**
+ * @return Query
+ * @since 7.2.0
+ */
+ public function getShopLocalesGql(): Query
+ {
+ return $this->createQuery('shopLocales', [
+ 'locale',
+ 'primary',
+ ], function(QueryBuilder $builder) {
+ $builder->setArgument('published', true);
+ });
+ }
+
/**
* @param bool $update
* @return array|null
@@ -176,7 +203,7 @@ public function getShop(bool $update = false): ?array
$shopRecord = new ShopifyData();
}
- $shopRecord->shopifyId = $response['id'];
+ $shopRecord->shopifyGid = $response['id'];
$shopRecord->type = 'Shop';
$shopRecord->data = $response;
@@ -203,6 +230,7 @@ public function getShop(bool $update = false): ?array
*/
public function getProductGql(?string $id = null): Query
{
+ // Create contextual pricing fields (if required)
$contextualPricingCountries = Plugin::getInstance()->getSettings()->getContextualPricingCountries();
$contextualPricing = [];
@@ -231,6 +259,32 @@ public function getProductGql(?string $id = null): Query
}
}
+ // Create translations fields (if required)
+ $translations = [];
+ if (in_array('productTranslations', Plugin::getInstance()->getSettings()->getAdditionalFeatures())) {
+ try {
+ $cacheKey = 'shopify:shopLocales:' . Plugin::getInstance()->getSettings()->getHostName();
+ $locales = Craft::$app->getCache()->getOrSet($cacheKey, function() {
+ return $this->query($this->getShopLocalesGql());
+ }, 86400);
+
+ if (empty($locales)) {
+ throw new \Exception('Shop locales data not found in the response.');
+ }
+
+ foreach ($locales as $locale) {
+ if ($locale['primary']) {
+ continue;
+ }
+
+ $localeKey = sprintf('translations_%1$s: translations(locale:"%1$s")', $locale['locale']);
+ $translations[$localeKey] = ['key', 'value'];
+ }
+ } catch (\Exception $e) {
+ Craft::error($e->getMessage(), __METHOD__);
+ }
+ }
+
$fields = [
'edges' => [
'node' => [
@@ -345,6 +399,8 @@ public function getProductGql(?string $id = null): Query
],
],
'vendor',
+ // Add translations to the products query
+ ...$translations,
],
],
];
@@ -359,8 +415,8 @@ public function getProductGql(?string $id = null): Query
return $this->createQuery('products', $fields, function(QueryBuilder $builder) use ($id) {
if ($id) {
- // Strip Shopify prefix if it exists
- $id = str_replace('gid://shopify/Product/', '', $id);
+ // Extract the numeric ID from a full GID or pass through a bare numeric ID
+ $id = StringHelper::afterLast($id, '/') ?: $id;
$builder->setArgument('query', sprintf('id:%s', $id));
}
@@ -596,19 +652,28 @@ public function initializeContext(): void
/** @var MonologTarget $webLogTarget */
$webLogTarget = Craft::$app->getLog()->targets['web'];
- Context::initialize(
- apiKey: $pluginSettings->getClientId(),
- apiSecretKey: $pluginSettings->getClientSecret(),
- scopes: ['write_products', 'read_products', 'read_inventory'],
+ $contextConfig = [
+ 'apiKey' => $pluginSettings->getClientId(),
+ 'apiSecretKey' => $pluginSettings->getClientSecret(),
+ 'scopes' => $pluginSettings->getScopes(true),
// This `hostName` is different from the `shop` value used when creating a Session!
// Shopify wants a name for the host/environment that is *initiating* the API connection.
// Internally, they appear to use this for starting OAuth flows and creating webhooks (but we handle the latter, manually).
- hostName: !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost',
- sessionStorage: new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'),
- apiVersion: $pluginSettings->getApiVersion(),
- isEmbeddedApp: false,
- logger: $webLogTarget->getLogger(),
- );
+ 'hostName' => !Craft::$app->request->isConsoleRequest ? Craft::$app->getRequest()->getHostName() : 'localhost',
+ 'sessionStorage' => new FileSessionStorage(Craft::$app->getPath()->getStoragePath() . DIRECTORY_SEPARATOR . 'shopify_api_sessions'),
+ 'apiVersion' => $pluginSettings->getApiVersion(),
+ 'isEmbeddedApp' => false,
+ 'logger' => $webLogTarget->getLogger(),
+ ];
+
+ if ($this->hasEventHandlers(self::EVENT_DEFINE_CONTEXT_CONFIG)) {
+ $event = new DefineContextConfigEvent(['config' => $contextConfig]);
+
+ $this->trigger(self::EVENT_DEFINE_CONTEXT_CONFIG, $event);
+ $contextConfig = $event->config;
+ }
+
+ Context::initialize(...$contextConfig);
Context::$HTTP_CLIENT_FACTORY = new class() extends HttpClientFactory {
public function client(): ClientInterface
@@ -617,6 +682,10 @@ public function client(): ClientInterface
return new Client(['headers' => ['X-Shopify-Api-Features' => 'include-presentment-prices']]);
}
};
+
+ if ($this->hasEventHandlers(self::EVENT_CONTEXT_INITIALIZED)) {
+ $this->trigger(self::EVENT_CONTEXT_INITIALIZED);
+ }
}
/**
diff --git a/src/services/BulkOperations.php b/src/services/BulkOperations.php
index 8e6cb586..cdb9e1db 100644
--- a/src/services/BulkOperations.php
+++ b/src/services/BulkOperations.php
@@ -66,14 +66,26 @@ public function getAllBulkOperations(): Collection
return collect($bulkOps);
}
+ /**
+ * @param string $gid
+ * @return BulkOperation|null
+ * @throws InvalidConfigException
+ * @since 8.0.0
+ */
+ public function getBulkOperationByShopifyGid(string $gid): ?BulkOperation
+ {
+ return $this->getAllBulkOperations()->firstWhere('shopifyGid', $gid);
+ }
+
/**
* @param string $shopifyId
* @return BulkOperation|null
* @throws InvalidConfigException
+ * @deprecated in 8.0.0. Use [[getBulkOperationByShopifyGid()]] instead.
*/
public function getBulkOperationByShopifyId(string $shopifyId): ?BulkOperation
{
- return $this->getAllBulkOperations()->firstWhere('shopifyId', $shopifyId);
+ return $this->getBulkOperationByShopifyGid($shopifyId);
}
/**
@@ -211,7 +223,7 @@ public function nextBulkOperation(): bool
}
$bulkOperation->shopifyStatus = $op['status'];
- $bulkOperation->shopifyId = $op['id'];
+ $bulkOperation->shopifyGid = $op['id'];
$this->saveBulkOperation($bulkOperation);
return true;
@@ -237,7 +249,7 @@ public function handleBulkOperationFinished(array $payload): void
}
// Load our local record of the bulk op:
- $bulkOperation = $this->getBulkOperationByShopifyId($payload['admin_graphql_api_id']);
+ $bulkOperation = $this->getBulkOperationByShopifyGid($payload['admin_graphql_api_id']);
if (!$bulkOperation) {
// Ok... maybe it was initiated for a different environment?
@@ -321,7 +333,7 @@ public function queueNextBulkOperation(): bool
/** @var BulkOperation $bulkOperation */
$bulkOperation = Craft::createObject(array_merge($nextToProcess, ['class' => BulkOperation::class]));
if (!Queue::push(new ProcessBulkOperationData([
- 'bulkOperationShopifyId' => $bulkOperation->shopifyId,
+ 'bulkOperationShopifyGid' => $bulkOperation->shopifyGid,
'dataUrl' => $bulkOperation->url,
'objectCount' => $bulkOperation->objectCount,
'clearData' => $bulkOperation->clearData,
@@ -351,7 +363,7 @@ public function saveBulkOperation(BulkOperation $bulkOperation, bool $runValidat
if ($bulkOperation->id) {
$record = BulkOperationRecord::findOne($bulkOperation->id);
} else {
- $record = BulkOperationRecord::findOne(['shopifyId' => $bulkOperation->shopifyId]);
+ $record = BulkOperationRecord::findOne(['shopifyGid' => $bulkOperation->shopifyGid]);
}
if (!$record) {
@@ -365,7 +377,7 @@ public function saveBulkOperation(BulkOperation $bulkOperation, bool $runValidat
}
$record->clearData = $bulkOperation->clearData;
- $record->shopifyId = $bulkOperation->shopifyId;
+ $record->shopifyGid = $bulkOperation->shopifyGid;
$record->url = $bulkOperation->url;
$record->objectCount = $bulkOperation->objectCount;
$record->query = $bulkOperation->query;
@@ -445,7 +457,7 @@ private function _createBulkOperationQuery(): Query
'id',
'objectCount',
'query',
- 'shopifyId',
+ 'shopifyGid',
'status',
'shopifyStatus',
'url',
diff --git a/src/services/Products.php b/src/services/Products.php
index 52483321..f89e2a53 100644
--- a/src/services/Products.php
+++ b/src/services/Products.php
@@ -8,12 +8,10 @@
use craft\events\ConfigEvent;
use craft\helpers\ArrayHelper;
use craft\helpers\Db;
-use craft\helpers\Json;
use craft\helpers\ProjectConfig;
use craft\helpers\StringHelper;
use craft\models\FieldLayout;
use craft\shopify\collections\VariantCollection;
-use craft\shopify\db\Table;
use craft\shopify\elements\Product;
use craft\shopify\events\ShopifyProductSyncEvent;
use craft\shopify\models\Variant;
@@ -58,16 +56,29 @@ class Products extends Component
*/
public const EVENT_BEFORE_SYNCHRONIZE_PRODUCT = 'beforeSynchronizeProduct';
+ /**
+ * @param string $gid
+ * @return void
+ * @throws InvalidConfigException
+ * @throws \yii\db\Exception
+ * @since 8.0.0
+ */
+ public function syncProductByShopifyGid(string $gid): void
+ {
+ $gid = $this->normalizeShopifyGid($gid);
+ Plugin::getInstance()->getBulkOperations()->createBulkOperation((string)Plugin::getInstance()->getApi()->getProductGql($gid), $gid);
+ }
+
/**
* @param string $id
* @return void
* @throws InvalidConfigException
* @throws \yii\db\Exception
+ * @deprecated in 8.0.0. Use [[syncProductByShopifyGid()]] instead.
*/
public function syncProductByShopifyId(string $id): void
{
- $shopifyId = $this->normalizeShopifyGid($id);
- Plugin::getInstance()->getBulkOperations()->createBulkOperation((string)Plugin::getInstance()->getApi()->getProductGql($id), $shopifyId);
+ $this->syncProductByShopifyGid($id);
}
/**
@@ -100,7 +111,7 @@ public function syncProductByInventoryItemId($id): void
$productId = $item['variant']['product']['id'];
- $this->syncProductByShopifyId($productId);
+ $this->syncProductByShopifyGid($productId);
}
/**
@@ -178,52 +189,72 @@ public function normalizeShopifyGid(string $shopifyId, string $type = 'Product')
}
/**
- * Deletes a product element by the Shopify ID.
+ * Deletes a product element by the Shopify GID.
*
+ * @param string $gid
+ * @return bool Whether the deletion was performed. Returns false if `$gid` is empty.
+ * @throws \Throwable
+ * @throws StaleObjectException
+ * @since 8.0.0
+ */
+ public function deleteProductByShopifyGid(string $gid): bool
+ {
+ if (!$gid) {
+ return false;
+ }
+
+ $gid = $this->normalizeShopifyGid($gid);
+
+ if ($product = Product::find()->shopifyGid($gid)->one()) {
+ // We hard delete because it will have been hard deleted in Shopify
+ Craft::$app->getElements()->deleteElement($product, true);
+ }
+
+ $this->deleteShopifyDataByShopifyGid($gid);
+
+ return true;
+ }
+
+ /**
* @param $id
* @return void
* @throws \Throwable
* @throws StaleObjectException
+ * @deprecated in 8.0.0. Use [[deleteProductByShopifyGid()]] instead.
*/
public function deleteProductByShopifyId($id): void
{
- if ($id) {
- if ($product = Product::find()->shopifyId($id)->one()) {
- // We hard delete because it will have been hard deleted in Shopify
- Craft::$app->getElements()->deleteElement($product, true);
- }
-
- // Delete data in shopify data table
- // Delete the product data
- $shopifyId = $this->normalizeShopifyGid($id);
- $this->deleteShopifyDataByShopifyId($shopifyId);
+ if (!$id) {
+ return;
}
+
+ $this->deleteProductByShopifyGid($id);
}
/**
- * @param string $shopifyId
+ * @param string $gid
* @return void
* @throws StaleObjectException
* @throws \Throwable
- * @since 6.0.0
+ * @since 8.0.0
*/
- public function deleteShopifyDataByShopifyId(string $shopifyId): void
+ public function deleteShopifyDataByShopifyGid(string $gid): void
{
- // Support both id and gid
- $shopifyId = $this->normalizeShopifyGid($shopifyId);
+ // Support both numeric ID and GID
+ $gid = $this->normalizeShopifyGid($gid);
/** @var ShopifyData|null $shopifyData */
- $shopifyData = ShopifyData::find()->where(['shopifyId' => $shopifyId])->one();
+ $shopifyData = ShopifyData::find()->where(['shopifyGid' => $gid])->one();
// Delete if possible
$shopifyData?->delete();
// Delete any child data that may still exist
/** @var ShopifyData[] $shopifyData */
- $shopifyData = ShopifyData::find()->where(['parentId' => $shopifyId])->all();
+ $shopifyData = ShopifyData::find()->where(['parentId' => $gid])->all();
$childIds = [];
foreach ($shopifyData as $data) {
- $childIds[] = $data->shopifyId;
+ $childIds[] = $data->shopifyGid;
$data->delete();
}
@@ -233,12 +264,24 @@ public function deleteShopifyDataByShopifyId(string $shopifyId): void
/** @var ShopifyData[] $shopifyData */
$shopifyData = ShopifyData::find()->where(['parentId' => $childId])->all();
foreach ($shopifyData as $data) {
- $childIds[] = $data->shopifyId;
+ $childIds[] = $data->shopifyGid;
$data->delete();
}
}
}
+ /**
+ * @param string $shopifyId
+ * @return void
+ * @throws StaleObjectException
+ * @throws \Throwable
+ * @deprecated in 8.0.0. Use [[deleteShopifyDataByShopifyGid()]] instead.
+ */
+ public function deleteShopifyDataByShopifyId(string $shopifyId): void
+ {
+ $this->deleteShopifyDataByShopifyGid($shopifyId);
+ }
+
/**
* @param array|Product[] $products
* @return array
@@ -247,23 +290,7 @@ public function deleteShopifyDataByShopifyId(string $shopifyId): void
public function eagerLoadMetafieldsForProducts(array $products): array
{
return $this->_eagerLoadTypeOnProducts($products, 'Metafield', function($product, $rows) {
- $metafields = collect($rows)
- ->mapWithKeys(function($d, $key) {
- /** @var ShopifyData $d */
- $data = Json::decodeIfJson($d->data);
-
- // Map if the data has `key` and `value` properties
- if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) {
- return [];
- }
-
- return [
- $data['key'] => Json::decodeIfJson($data['value']),
- ];
- })
- ->all();
-
- $product->setMetafields($metafields);
+ $product->setMetafields($rows);
});
}
@@ -290,7 +317,7 @@ public function eagerLoadVariantsForProducts(array $products): array
$variantsByProductId = [];
$return = $this->_eagerLoadTypeOnProducts($products, 'ProductVariant', function($product, $rows) use (&$variantsByProductId, &$variantIds) {
foreach ($rows as $row) {
- $variantIds[] = $row->shopifyId;
+ $variantIds[] = $row->shopifyGid;
}
$variantsByProductId[$product->shopifyGid] = $rows;
@@ -309,18 +336,10 @@ public function eagerLoadVariantsForProducts(array $products): array
$variants = VariantCollection::make($variantsByProductId[$product->shopifyGid]);
if ($metafieldsData->isNotEmpty()) {
- $variants->map(function(Variant$variant) use ($metafieldsData) {
- $metafields = $metafieldsData->get($variant->shopifyId);
+ $variants->map(function(Variant $variant) use ($metafieldsData) {
+ $metafields = $metafieldsData->get($variant->shopifyGid);
if (!empty($metafields)) {
- $variant->setMetafields(collect($metafields)->mapWithKeys(function($d) {
- $data = Json::decodeIfJson($d->data);
- if (!is_array($data) || !isset($data['key']) || !isset($data['value'])) {
- return [];
- }
- return [
- $data['key'] => Json::decodeIfJson($data['value']),
- ];
- })->all());
+ $variant->setMetafields($metafields->all());
}
});
}
diff --git a/src/translations/en/shopify.php b/src/translations/en/shopify.php
index 78fcda77..37b9dffb 100644
--- a/src/translations/en/shopify.php
+++ b/src/translations/en/shopify.php
@@ -14,96 +14,119 @@
* @since 0.0.1
*/
return [
+ 'A comma separated list of country codes used to return contextual pricing.' => 'A comma separated list of country codes used to return contextual pricing.',
+ 'A comma separated list of custom scopes to add to the API requests.' => 'A comma separated list of custom scopes to add to the API requests.',
+ 'API scopes required for your app integration, including additional features and custom scopes.' => 'API scopes required for your app integration, including additional features and custom scopes.',
'API Connection' => 'API Connection',
'Add a product' => 'Add a product',
'All products' => 'All products',
'Archived in Shopify' => 'Archived in Shopify',
'Are you sure you want to run a complete sync of all products?' => 'Are you sure you want to run a complete sync of all products?',
- 'Are you sure you want to delete this sync?' => 'Are you sure you want to delete this sync?',
- 'Are you sure you want to delete this webhook?' => 'Are you sure you want to delete this webhook?',
- 'Channel' => 'Channel',
+ 'Are you sure you want to delete the {topic} webhook?' => 'Are you sure you want to delete the {topic} webhook?',
+ 'Authorization' => 'Authorization',
+ 'Authorize' => 'Authorize',
+ 'Authorize App' => 'Authorize App',
'Completed' => 'Completed',
+ 'Configure the product’s front-end routing settings.' => 'Configure the product’s front-end routing settings.',
+ 'Context Pricing Countries' => 'Context Pricing Countries',
+ 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.' => 'Copy these scopes into your Shopify app’s configuration in the Dev Dashboard to ensure your integration works correctly.',
'Couldn’t save settings.' => 'Couldn’t save settings.',
- 'Create' => 'Create',
+ 'Couldn’t update scopes.' => 'Couldn’t update scopes.',
+ 'Create all webhooks' => 'Create all webhooks',
+ 'Create missing webhooks' => 'Create missing webhooks',
'Created' => 'Created',
'Created At' => 'Created At',
- 'Delete {topic} webhook?' => 'Delete {topic} webhook?',
- 'Description HTML' => 'Description HTML',
+ 'Created at' => 'Created at',
+ 'Custom Scopes' => 'Custom Scopes',
+ 'Delete {topic} webhook' => 'Delete {topic} webhook',
'Draft in Shopify' => 'Draft in Shopify',
'Edit variant {title} on Shopify' => 'Edit variant {title} on Shopify',
+ 'Error authorizing app' => 'Error authorizing app',
'Failed to create products sync' => 'Failed to create products sync',
'Failed to delete sync' => 'Failed to delete sync',
- 'General' => 'General',
'Handle' => 'Handle',
'Has variants' => 'Has variants',
+ 'Images' => 'Images',
+ 'Invalid or missing HMAC. Please try re-installing the app.' => 'Invalid or missing HMAC. Please try re-installing the app.',
'Key' => 'Key',
+ 'Leave blank if products don’t have URLs' => 'Leave blank if products don’t have URLs',
'Live' => 'Live',
'Media' => 'Media',
'Meta fields' => 'Meta fields',
'Meta Fields' => 'Meta Fields',
'New Product' => 'New Product',
- 'No Shopify session available.' => 'No Shopify session available.',
- 'No webhooks exist for this environment' => 'No webhooks exist for this environment',
- 'Objects' => 'Objects',
+ 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.' => 'No Shopify session available. Please check your credentials and re-authorize the application, if necessary.',
+ 'No webhooks exist for this environment.' => 'No webhooks exist for this environment.',
+ 'Open in Shopify' => 'Open in Shopify',
'Option' => 'Option',
'Options' => 'Options',
'Price' => 'Price',
'Processing' => 'Processing',
'Processing bulk operation data' => 'Processing bulk operation data',
'Product Template' => 'Product Template',
+ 'Product Translations' => 'Product Translations',
'Product Type' => 'Product Type',
+ 'Product URI Format' => 'Product URI Format',
'Product URI format' => 'Product URI format',
'Product' => 'Product',
'Products sync created' => 'Products sync created',
'Products' => 'Products',
'Published At' => 'Published At',
- 'Published Scope' => 'Published Scope',
- 'Published' => 'Published',
+ 'Published at' => 'Published at',
'Queued' => 'Queued',
+ 'Routing Settings' => 'Routing Settings',
+ 'Scopes' => 'Scopes',
'Settings saved.' => 'Settings saved.',
'Settings' => 'Settings',
'Shopify Client ID' => 'Shopify Client ID',
'Shopify Client Secret Key' => 'Shopify Client Secret Key',
'Shopify API Version' => 'Shopify API Version',
- 'Shopify Access Token' => 'Shopify Access Token',
+ 'Shopify App Auth URL' => 'Shopify App Auth URL',
'Shopify Edit' => 'Shopify Edit',
'Shopify Host Name' => 'Shopify Host Name',
'Shopify ID' => 'Shopify ID',
- 'Shopify Product' => 'Shopify Product',
'Shopify Products' => 'Shopify Products',
+ 'Shopify product' => 'Shopify product',
+ 'Shopify products' => 'Shopify products',
'Shopify Status' => 'Shopify Status',
- 'Shopify plugin loaded' => 'Shopify plugin loaded',
+ 'Shopify Sync' => 'Shopify Sync',
'Shopify' => 'Shopify',
'SKU' => 'SKU',
'Status' => 'Status',
'Supported API versions: {versions}' => 'Supported API versions: {versions}',
'Sync all' => 'Sync all',
- 'Sync all Products' => 'Sync all Products',
- 'Sync could not be deleted' => 'Sync could not be deleted',
'Sync deleted' => 'Sync deleted',
'Tags' => 'Tags',
'Template suffix' => 'Template suffix',
+ 'The host name must be a valid Shopify store domain.' => 'The host name must be a valid Shopify store domain.',
+ 'The Shopify store hostname.' => 'The Shopify store hostname.',
+ 'The Shopify store {shop} needs to be authorized to connect with this plugin.' => 'The Shopify store {shop} needs to be authorized to connect with this plugin.',
+ 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.' => 'The URL of your Shopify app in the Dev Dashboard. This is automatically generated from your CP URL.',
+ 'This environment is not subscribed to all the required webhook topics.' => 'This environment is not subscribed to all the required webhook topics.',
+ 'This environment is subscribed to all the required webhook topics!' => 'This environment is subscribed to all the required webhook topics!',
'This product has no media.' => 'This product has no media.',
'This product has no meta fields.' => 'This product has no meta fields.',
'This product has no options.' => 'This product has no options.',
'This product has no variants.' => 'This product has no variants.',
+ 'Topic' => 'Topic',
'Total variants' => 'Total variants',
- 'Unpublished' => 'Unpublished',
+ 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.' => 'Unable to connect to custom app. Syncing will be unavailable until the app has been authorized.',
'Untitled product' => 'Untitled product',
'Updated At' => 'Updated At',
- 'Updating product metafields for “{title}”' => 'Updating product metafields for “{title}”',
- 'Updating product variants for “{title}”' => 'Updating product variants for “{title}”',
+ 'Updated at' => 'Updated at',
'Value' => 'Value',
'Values' => 'Values',
'Variant' => 'Variant',
'Variants' => 'Variants',
'Vendor' => 'Vendor',
'View products' => 'View products',
+ 'Webhook could not be deleted' => 'Webhook could not be deleted',
'Webhook deleted' => 'Webhook deleted',
- 'Webhooks could not be deleted' => 'Webhooks could not be deleted',
'Webhooks could not be registered.' => 'Webhooks could not be registered.',
- 'Webhooks for the current environment.' => 'Webhooks for the current environment.',
'Webhooks registered.' => 'Webhooks registered.',
'Webhooks' => 'Webhooks',
+ 'What product URIs should look like.' => 'What product URIs should look like.',
+ 'Which template should be loaded when a product’s URL is requested.' => 'Which template should be loaded when a product’s URL is requested.',
+ 'Your Shopify app has been successfully authorized.' => 'Your Shopify app has been successfully authorized.',
'{name} option values: {values}' => '{name} option values: {values}',
];
diff --git a/src/utilities/Sync.php b/src/utilities/Sync.php
index cf2996bc..bedccf78 100644
--- a/src/utilities/Sync.php
+++ b/src/utilities/Sync.php
@@ -39,15 +39,6 @@ public static function id(): string
return 'shopify-sync';
}
- /**
- * @inheritdoc
- * @TODO remove this method when support for Craft 4 is dropped
- */
- public static function iconPath(): ?string
- {
- return self::icon(); // TODO: Change the autogenerated stub
- }
-
/**
* @inheritdoc
*/
diff --git a/tests/fixtures/BulkOperationsFixture.php b/tests/fixtures/BulkOperationsFixture.php
index 1b4fd2f9..486b52ac 100644
--- a/tests/fixtures/BulkOperationsFixture.php
+++ b/tests/fixtures/BulkOperationsFixture.php
@@ -26,7 +26,7 @@ public function load(): void
foreach ($rows as $key => $row) {
$model = new BulkOperation();
- $model->shopifyId = $row['shopifyId'];
+ $model->shopifyGid = $row['shopifyGid'];
$model->url = $row['url'];
$model->objectCount = (int)($row['objectCount'] ?? 0);
$model->query = $row['query'] ?? null;
diff --git a/tests/fixtures/ShopifyDataFixture.php b/tests/fixtures/ShopifyDataFixture.php
index 62144fbe..db8892cb 100644
--- a/tests/fixtures/ShopifyDataFixture.php
+++ b/tests/fixtures/ShopifyDataFixture.php
@@ -29,7 +29,7 @@ public function load(): void
foreach ($rows as $row) {
$uid = $row['uid'];
\Yii::$app->db->createCommand()->insert(Table::DATA, [
- 'shopifyId' => $row['shopifyId'],
+ 'shopifyGid' => $row['shopifyGid'],
'type' => $row['type'],
'data' => is_array($row['data']) ? json_encode($row['data']) : $row['data'],
'parentId' => $row['parentId'],
diff --git a/tests/fixtures/data/shopify-bulk-operations.php b/tests/fixtures/data/shopify-bulk-operations.php
index d3774db8..4725c0d8 100644
--- a/tests/fixtures/data/shopify-bulk-operations.php
+++ b/tests/fixtures/data/shopify-bulk-operations.php
@@ -3,7 +3,7 @@
return [
'op_completed_74' => [
'id' => 74,
- 'shopifyId' => 'gid://shopify/BulkOperation/4848685842483',
+ 'shopifyGid' => 'gid://shopify/BulkOperation/4848685842483',
'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/45bd3bca5ff2b0445c4ee86c7b6a?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781775894&Signature=gYorZwhi9arbpjvRo5p%2BqkRn9NYhDNwmi6ZJ%2FHSH3BgiQqu62pByOOUpZ%2BG3Qe4QI%2Frc%2F8XJYoSudrjrRBtnqn%2Fess73avZYDa7%2BmG4qPyJ%2BToJu04H5xYo%2FiIbSj6mJsHJJ1td2w%2B0O3hbbcPmVFaEZkR8vZpfa0MkC8aPO4VFbj%2FfnJdTDr5kmLV5E6PdlFy8vfKCBsXoj4Hh0dDImi9IFSEjEx80hc7%2FXtxBe374vam%2BM%2BGY0KCFglfNmnWyPjGJtnjXciRprOkCJ%2Fcz5vlNbYiR%2FAdRKF6i51HeztiBmD8vCrstiktNug9wruwYcawmGqQiXa4oJMLAWKOe4lg%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4848685842483.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4848685842483.jsonl&response-content-type=application%2Fjsonl',
'objectCount' => 60,
'query' => 'query { products(query: "id:6656149192755") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor } } } }',
@@ -16,7 +16,7 @@
],
'op_completed_73' => [
'id' => 73,
- 'shopifyId' => 'gid://shopify/BulkOperation/4846181023795',
+ 'shopifyGid' => 'gid://shopify/BulkOperation/4846181023795',
'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/0b38d4b425e19b4600ddc7d6c366?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781705438&Signature=PMTzLXL%2B7Nb%2B5RCPXypekUy8BmX1AZTfqIxT0TkwKe7%2FJ4T5HRCuZySB4gZN5wCq%2Fe39DKtGFKuJwscJOoWp4tSKGCnPNtZECvzNnZKo8rDZurffaTFOWsHIgJ2LqlMYF%2BPm675YY0Lh%2FfWDcp5LgWBGCgxRHwGa2YcAyRxE8jJBFC4b8wXmJEQ8bnEKOB5AQpeVK5p%2B7PF5kSXSMp%2F83KAUETJUnY2KtlczABAo8JcyoHBVj7n6pZhVHal%2Bz1HYdC8rZZzO%2FsA3iJ2mX7z%2FrUFaZAoGIrazPJXninpLyA6wz%2F4y41HDV390ekMj6va4tu%2BIA1VuZo8MN4FHnelnSQ%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846181023795.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846181023795.jsonl&response-content-type=application%2Fjsonl',
'objectCount' => 3,
'query' => 'query { products(query: "id:7136093208627") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }',
@@ -29,7 +29,7 @@
],
'op_completed_72' => [
'id' => 72,
- 'shopifyId' => 'gid://shopify/BulkOperation/4846126170163',
+ 'shopifyGid' => 'gid://shopify/BulkOperation/4846126170163',
'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/1261ad672da99ee3b988a510cef2?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781703757&Signature=anJScMX3XZ4HGBwJbEnGsyQElHcJG%2BplwGMocdYEL%2FTJoiF13cSQAV6CKKiQ7hfzlrkuHt1QQAZl68TX38SJLtvVBXE64hA2C7eLAb9ncpJvBCcXo54TjCGkyns54dBYjsmNtOXdHBuIbAsjbFQ52Elw3QZQV57yjITmqEgDkHdqpgb011C0bxX4lpwMW4e%2FpV0JgITFeYJrfRNcFEgu6RzsWQvuj8114VSvqgHGN2k50AVyUtvY9b39wsvZTNOP9%2FNqdary%2BO0RPY7NqmQUF%2BHD6cBm%2F2EF%2F%2B0RsfpdUJ3loWUtXAuOH9kIZP2DZFnoxoAYu6QGjSNLP01V%2F3hvSA%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846126170163.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846126170163.jsonl&response-content-type=application%2Fjsonl',
'objectCount' => 3,
'query' => 'query { products(query: "id:7136068075571") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }',
@@ -42,7 +42,7 @@
],
'op_completed_71' => [
'id' => 71,
- 'shopifyId' => 'gid://shopify/BulkOperation/4846010433587',
+ 'shopifyGid' => 'gid://shopify/BulkOperation/4846010433587',
'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/6a97ef72dca29699d67ba70d3e8b?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781700339&Signature=mfZ76fGjrnk9G04sllkiktezTLFsLoikUhqftMHP4CNt5fVgaGIQY1%2Buvh2lVKLXGlIH%2F44WXiAZD%2F0gK28gRnT0kBLcENPR3ZmoAmpFHDGbB4MKEv6bUkrJv7wlxZD0ZilyYgnVLEyNuV3CvLgqe9HRz3S8bfeIH3jfxt5qxCzfcdtNIoRg0qmkageYF6drLCdCQ7y7UPvCU9e0De9HEuiaycfFty6Gyc2%2BO1pFCxbQu0I6hZJbSbney3QJvdatNo8j05zWFFalPI9iPXUZnHxGm6Sc4ISP2FZCcutcJ9r617%2F5uSYSsHi%2F%2F5e0Deb%2FHAEQJYiZJk8YXHL7tvNqHQ%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4846010433587.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4846010433587.jsonl&response-content-type=application%2Fjsonl',
'objectCount' => 3,
'query' => 'query { products(query: "id:7136099729459") { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }',
@@ -55,7 +55,7 @@
],
'op_completed_70' => [
'id' => 70,
- 'shopifyId' => 'gid://shopify/BulkOperation/4845996933171',
+ 'shopifyGid' => 'gid://shopify/BulkOperation/4845996933171',
'url' => 'https://storage.googleapis.com/shopify-tiers-assets-prod-us-east1/bulk-operation-outputs/45e4ebfb3778f9f27c5709136189?GoogleAccessId=assets-us-prod%40shopify-tiers.iam.gserviceaccount.com&Expires=1781700017&Signature=XOm1n1r681pq%2B7iQpcLHD%2FMmYOqv8Fa33LOcE8LSdYLezdRDBR6wiYZ2jCNvtA5nOGBzUe3XDadhTbaxYVqlIaeZDx1HBB14wPfHjbLmNU5hRXa0094tQiw9IU23iCi4lM%2Bc2PX5SrC5WJbFwcDrl%2BiOzrazNpjgp5rVnW4QWPepNaN7TBeiHMHBqxNnY7FxCEi2FQxK6cchM1Rjuro0QmtNufRxiuw9vhxbjf8b6VOtZx4agdnNms3Rhjioq7191qfdeLvzaz2aepXoJd%2BhAVAmJo5wgMkgRF2wddgCtrYe6SHFdxP%2BCrlV1XHpbOQiI1G52%2BQorUps3EOlX8rHfg%3D%3D&response-content-disposition=attachment%3B+filename%3D%22staging-bulk-4845996933171.jsonl%22%3B+filename%2A%3DUTF-8%27%27staging-bulk-4845996933171.jsonl&response-content-type=application%2Fjsonl',
'objectCount' => 1880,
'query' => 'query { products { edges { node { descriptionHtml createdAt handle id media { edges { node { mediaContentType alt id ... on MediaImage { createdAt updatedAt image { altText height width url } } } } } metafields { edges { node { id key value } } } options { id name position values optionValues { id name hasVariants } } productType publishedAt status tags templateSuffix title totalInventory updatedAt variants { edges { node { usContextualPricing:contextualPricing(context:{country:US}) { price { amount currencyCode } compareAtPrice { amount currencyCode } } id barcode compareAtPrice createdAt displayName price sku taxable title updatedAt position image { altText height id url width originalSrc src transformedSrc } inventoryItem { id countryCodeOfOrigin createdAt updatedAt sku tracked unitCost { amount currencyCode } } inventoryPolicy inventoryQuantity metafields { edges { node { id key value } } } product { id } selectedOptions { name value } } } } vendor translations_es: translations(locale:"es") { key value } } } } }',
diff --git a/tests/fixtures/data/shopify-data.php b/tests/fixtures/data/shopify-data.php
index 183d5813..7b1aca5d 100644
--- a/tests/fixtures/data/shopify-data.php
+++ b/tests/fixtures/data/shopify-data.php
@@ -3,7 +3,7 @@
return [
'Product_product_7136060145715' => [
'id' => 64533,
- 'shopifyId' => 'gid://shopify/Product/7136060145715',
+ 'shopifyGid' => 'gid://shopify/Product/7136060145715',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136060145715',
@@ -126,7 +126,7 @@
],
'Product_product_7136060964915' => [
'id' => 64581,
- 'shopifyId' => 'gid://shopify/Product/7136060964915',
+ 'shopifyGid' => 'gid://shopify/Product/7136060964915',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136060964915',
@@ -262,7 +262,7 @@
],
'Product_product_7136062865459' => [
'id' => 64674,
- 'shopifyId' => 'gid://shopify/Product/7136062865459',
+ 'shopifyGid' => 'gid://shopify/Product/7136062865459',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136062865459',
@@ -336,7 +336,7 @@
],
'Product_product_7136074399795' => [
'id' => 65044,
- 'shopifyId' => 'gid://shopify/Product/7136074399795',
+ 'shopifyGid' => 'gid://shopify/Product/7136074399795',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136074399795',
@@ -396,7 +396,7 @@
],
'Product_product_7136075251763' => [
'id' => 65080,
- 'shopifyId' => 'gid://shopify/Product/7136075251763',
+ 'shopifyGid' => 'gid://shopify/Product/7136075251763',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136075251763',
@@ -456,7 +456,7 @@
],
'Product_product_7136076070963' => [
'id' => 65107,
- 'shopifyId' => 'gid://shopify/Product/7136076070963',
+ 'shopifyGid' => 'gid://shopify/Product/7136076070963',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136076070963',
@@ -515,7 +515,7 @@
],
'Product_product_7136089669683' => [
'id' => 65620,
- 'shopifyId' => 'gid://shopify/Product/7136089669683',
+ 'shopifyGid' => 'gid://shopify/Product/7136089669683',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136089669683',
@@ -574,7 +574,7 @@
],
'Product_product_7136090816563' => [
'id' => 65644,
- 'shopifyId' => 'gid://shopify/Product/7136090816563',
+ 'shopifyGid' => 'gid://shopify/Product/7136090816563',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136090816563',
@@ -633,7 +633,7 @@
],
'Product_product_7136093863987' => [
'id' => 65773,
- 'shopifyId' => 'gid://shopify/Product/7136093863987',
+ 'shopifyGid' => 'gid://shopify/Product/7136093863987',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136093863987',
@@ -688,7 +688,7 @@
],
'Product_product_7136099500083' => [
'id' => 66025,
- 'shopifyId' => 'gid://shopify/Product/7136099500083',
+ 'shopifyGid' => 'gid://shopify/Product/7136099500083',
'type' => 'Product',
'data' => [
'id' => 'gid://shopify/Product/7136099500083',
@@ -749,7 +749,7 @@
],
'MediaImage_mediaimage_23117943373875' => [
'id' => 64534,
- 'shopifyId' => 'gid://shopify/MediaImage/23117943373875',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117943373875',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117943373875',
@@ -772,7 +772,7 @@
],
'MediaImage_mediaimage_23117943406643' => [
'id' => 64535,
- 'shopifyId' => 'gid://shopify/MediaImage/23117943406643',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117943406643',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117943406643',
@@ -795,7 +795,7 @@
],
'MediaImage_mediaimage_23117943439411' => [
'id' => 64536,
- 'shopifyId' => 'gid://shopify/MediaImage/23117943439411',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117943439411',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117943439411',
@@ -818,7 +818,7 @@
],
'MediaImage_mediaimage_23117943472179' => [
'id' => 64537,
- 'shopifyId' => 'gid://shopify/MediaImage/23117943472179',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117943472179',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117943472179',
@@ -841,7 +841,7 @@
],
'MediaImage_mediaimage_23117943504947' => [
'id' => 64538,
- 'shopifyId' => 'gid://shopify/MediaImage/23117943504947',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117943504947',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117943504947',
@@ -864,7 +864,7 @@
],
'ProductVariant_productvariant_41966390083635' => [
'id' => 64539,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390083635',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390083635',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390083635',
@@ -919,7 +919,7 @@
],
'ProductVariant_productvariant_41966390116403' => [
'id' => 64540,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390116403',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390116403',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390116403',
@@ -974,7 +974,7 @@
],
'ProductVariant_productvariant_41966390149171' => [
'id' => 64541,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390149171',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390149171',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390149171',
@@ -1029,7 +1029,7 @@
],
'ProductVariant_productvariant_41966390181939' => [
'id' => 64542,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390181939',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390181939',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390181939',
@@ -1084,7 +1084,7 @@
],
'ProductVariant_productvariant_41966390214707' => [
'id' => 64543,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390214707',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390214707',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390214707',
@@ -1139,7 +1139,7 @@
],
'ProductVariant_productvariant_41966390247475' => [
'id' => 64544,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390247475',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390247475',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390247475',
@@ -1194,7 +1194,7 @@
],
'ProductVariant_productvariant_41966390280243' => [
'id' => 64545,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390280243',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390280243',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390280243',
@@ -1249,7 +1249,7 @@
],
'ProductVariant_productvariant_41966390313011' => [
'id' => 64546,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390313011',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390313011',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390313011',
@@ -1304,7 +1304,7 @@
],
'ProductVariant_productvariant_41966390345779' => [
'id' => 64547,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966390345779',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966390345779',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966390345779',
@@ -1359,7 +1359,7 @@
],
'MediaImage_mediaimage_23117946912819' => [
'id' => 64582,
- 'shopifyId' => 'gid://shopify/MediaImage/23117946912819',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117946912819',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117946912819',
@@ -1382,7 +1382,7 @@
],
'MediaImage_mediaimage_23117946945587' => [
'id' => 64583,
- 'shopifyId' => 'gid://shopify/MediaImage/23117946945587',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117946945587',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117946945587',
@@ -1405,7 +1405,7 @@
],
'MediaImage_mediaimage_23117946978355' => [
'id' => 64584,
- 'shopifyId' => 'gid://shopify/MediaImage/23117946978355',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117946978355',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117946978355',
@@ -1428,7 +1428,7 @@
],
'MediaImage_mediaimage_23117947011123' => [
'id' => 64585,
- 'shopifyId' => 'gid://shopify/MediaImage/23117947011123',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117947011123',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117947011123',
@@ -1451,7 +1451,7 @@
],
'MediaImage_mediaimage_23117947043891' => [
'id' => 64586,
- 'shopifyId' => 'gid://shopify/MediaImage/23117947043891',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117947043891',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117947043891',
@@ -1474,7 +1474,7 @@
],
'ProductVariant_productvariant_41966393098291' => [
'id' => 64587,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393098291',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393098291',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393098291',
@@ -1529,7 +1529,7 @@
],
'ProductVariant_productvariant_41966393131059' => [
'id' => 64588,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393131059',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393131059',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393131059',
@@ -1584,7 +1584,7 @@
],
'ProductVariant_productvariant_41966393163827' => [
'id' => 64589,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393163827',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393163827',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393163827',
@@ -1639,7 +1639,7 @@
],
'ProductVariant_productvariant_41966393196595' => [
'id' => 64590,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393196595',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393196595',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393196595',
@@ -1694,7 +1694,7 @@
],
'ProductVariant_productvariant_41966393229363' => [
'id' => 64591,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393229363',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393229363',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393229363',
@@ -1749,7 +1749,7 @@
],
'ProductVariant_productvariant_41966393262131' => [
'id' => 64592,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393262131',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393262131',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393262131',
@@ -1804,7 +1804,7 @@
],
'ProductVariant_productvariant_41966393294899' => [
'id' => 64593,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393294899',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393294899',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393294899',
@@ -1859,7 +1859,7 @@
],
'ProductVariant_productvariant_41966393327667' => [
'id' => 64594,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393327667',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393327667',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393327667',
@@ -1914,7 +1914,7 @@
],
'ProductVariant_productvariant_41966393360435' => [
'id' => 64595,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393360435',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393360435',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393360435',
@@ -1969,7 +1969,7 @@
],
'ProductVariant_productvariant_41966393393203' => [
'id' => 64596,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393393203',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393393203',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393393203',
@@ -2024,7 +2024,7 @@
],
'ProductVariant_productvariant_41966393425971' => [
'id' => 64597,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966393425971',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966393425971',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966393425971',
@@ -2079,7 +2079,7 @@
],
'MediaImage_mediaimage_23117955760179' => [
'id' => 64675,
- 'shopifyId' => 'gid://shopify/MediaImage/23117955760179',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117955760179',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117955760179',
@@ -2102,7 +2102,7 @@
],
'MediaImage_mediaimage_23117955825715' => [
'id' => 64676,
- 'shopifyId' => 'gid://shopify/MediaImage/23117955825715',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117955825715',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117955825715',
@@ -2125,7 +2125,7 @@
],
'MediaImage_mediaimage_23117955858483' => [
'id' => 64677,
- 'shopifyId' => 'gid://shopify/MediaImage/23117955858483',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117955858483',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117955858483',
@@ -2148,7 +2148,7 @@
],
'MediaImage_mediaimage_23117955891251' => [
'id' => 64678,
- 'shopifyId' => 'gid://shopify/MediaImage/23117955891251',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23117955891251',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23117955891251',
@@ -2171,7 +2171,7 @@
],
'ProductVariant_productvariant_41966400143411' => [
'id' => 64679,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966400143411',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966400143411',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966400143411',
@@ -2226,7 +2226,7 @@
],
'MediaImage_mediaimage_23118015758387' => [
'id' => 65045,
- 'shopifyId' => 'gid://shopify/MediaImage/23118015758387',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118015758387',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118015758387',
@@ -2249,7 +2249,7 @@
],
'ProductVariant_productvariant_41966466236467' => [
'id' => 65046,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966466236467',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966466236467',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966466236467',
@@ -2300,7 +2300,7 @@
],
'MediaImage_mediaimage_23118019788851' => [
'id' => 65081,
- 'shopifyId' => 'gid://shopify/MediaImage/23118019788851',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118019788851',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118019788851',
@@ -2323,7 +2323,7 @@
],
'ProductVariant_productvariant_41966470201395' => [
'id' => 65082,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966470201395',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966470201395',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966470201395',
@@ -2374,7 +2374,7 @@
],
'MediaImage_mediaimage_23118023426099' => [
'id' => 65108,
- 'shopifyId' => 'gid://shopify/MediaImage/23118023426099',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118023426099',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118023426099',
@@ -2397,7 +2397,7 @@
],
'ProductVariant_productvariant_41966471970867' => [
'id' => 65109,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966471970867',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966471970867',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966471970867',
@@ -2448,7 +2448,7 @@
],
'MediaImage_mediaimage_23118091616307' => [
'id' => 65621,
- 'shopifyId' => 'gid://shopify/MediaImage/23118091616307',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118091616307',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118091616307',
@@ -2471,7 +2471,7 @@
],
'ProductVariant_productvariant_41966558183475' => [
'id' => 65622,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966558183475',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966558183475',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966558183475',
@@ -2522,7 +2522,7 @@
],
'MediaImage_mediaimage_23118095417395' => [
'id' => 65645,
- 'shopifyId' => 'gid://shopify/MediaImage/23118095417395',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118095417395',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118095417395',
@@ -2545,7 +2545,7 @@
],
'ProductVariant_productvariant_41966567358515' => [
'id' => 65646,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966567358515',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966567358515',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966567358515',
@@ -2596,7 +2596,7 @@
],
'MediaImage_mediaimage_23118112358451' => [
'id' => 65774,
- 'shopifyId' => 'gid://shopify/MediaImage/23118112358451',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118112358451',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118112358451',
@@ -2619,7 +2619,7 @@
],
'ProductVariant_productvariant_41966582956083' => [
'id' => 65775,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966582956083',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966582956083',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966582956083',
@@ -2670,7 +2670,7 @@
],
'MediaImage_mediaimage_23118148141107' => [
'id' => 66026,
- 'shopifyId' => 'gid://shopify/MediaImage/23118148141107',
+ 'shopifyGid' => 'gid://shopify/MediaImage/23118148141107',
'type' => 'MediaImage',
'data' => [
'id' => 'gid://shopify/MediaImage/23118148141107',
@@ -2693,7 +2693,7 @@
],
'ProductVariant_productvariant_41966614183987' => [
'id' => 66027,
- 'shopifyId' => 'gid://shopify/ProductVariant/41966614183987',
+ 'shopifyGid' => 'gid://shopify/ProductVariant/41966614183987',
'type' => 'ProductVariant',
'data' => [
'id' => 'gid://shopify/ProductVariant/41966614183987',
diff --git a/tests/unit/elements/ProductTest.php b/tests/unit/elements/ProductTest.php
new file mode 100644
index 00000000..3d477b81
--- /dev/null
+++ b/tests/unit/elements/ProductTest.php
@@ -0,0 +1,398 @@
+shopifyGid = self::PRODUCT_GID;
+ $product->shopifyId = self::PRODUCT_ID;
+ $product->shopifyStatus = $shopifyStatus;
+ return $product;
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyId
+ // -------------------------------------------------------------------------
+
+ public function testShopifyIdIsNullByDefault(): void
+ {
+ $product = new Product();
+ self::assertNull($product->shopifyId);
+ }
+
+ public function testShopifyIdIsInt(): void
+ {
+ $product = $this->_makeProduct();
+ self::assertIsInt($product->shopifyId);
+ }
+
+ public function testShopifyIdCanBeSetOnNewElement(): void
+ {
+ $product = new Product();
+ $product->shopifyId = self::PRODUCT_ID;
+ self::assertEquals(self::PRODUCT_ID, $product->shopifyId);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyGid
+ // -------------------------------------------------------------------------
+
+ public function testShopifyGidIsNullByDefault(): void
+ {
+ $product = new Product();
+ self::assertNull($product->shopifyGid);
+ }
+
+ public function testShopifyGidIsString(): void
+ {
+ $product = $this->_makeProduct();
+ self::assertIsString($product->shopifyGid);
+ }
+
+ public function testShopifyGidHasCorrectFormat(): void
+ {
+ $product = $this->_makeProduct();
+ self::assertStringStartsWith('gid://shopify/Product/', $product->shopifyGid);
+ }
+
+ public function testShopifyGidCanBeSetOnNewElement(): void
+ {
+ $product = new Product();
+ $product->shopifyGid = self::PRODUCT_GID;
+ self::assertEquals(self::PRODUCT_GID, $product->shopifyGid);
+ }
+
+ public function testShopifyGidNumericSegmentIsNumeric(): void
+ {
+ $product = $this->_makeProduct();
+ $lastSegment = substr($product->shopifyGid, strrpos($product->shopifyGid, '/') + 1);
+ self::assertMatchesRegularExpression('/^\d+$/', $lastSegment);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyId and shopifyGid relationship
+ // -------------------------------------------------------------------------
+
+ public function testShopifyIdMatchesNumericSegmentOfGid(): void
+ {
+ $product = $this->_makeProduct();
+ $lastSegment = (int) substr($product->shopifyGid, strrpos($product->shopifyGid, '/') + 1);
+ self::assertEquals($lastSegment, $product->shopifyId);
+ }
+
+ public function testShopifyGidContainsShopifyId(): void
+ {
+ $product = $this->_makeProduct();
+ self::assertStringContainsString((string) $product->shopifyId, $product->shopifyGid);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyStatus
+ // -------------------------------------------------------------------------
+
+ public function testShopifyStatusDefaultsToActive(): void
+ {
+ $product = new Product();
+ self::assertEquals(Product::SHOPIFY_STATUS_ACTIVE, $product->shopifyStatus);
+ }
+
+ public function testShopifyStatusActiveConstantValue(): void
+ {
+ self::assertEquals('active', Product::SHOPIFY_STATUS_ACTIVE);
+ }
+
+ public function testShopifyStatusDraftConstantValue(): void
+ {
+ self::assertEquals('draft', Product::SHOPIFY_STATUS_DRAFT);
+ }
+
+ public function testShopifyStatusArchivedConstantValue(): void
+ {
+ self::assertEquals('archived', Product::SHOPIFY_STATUS_ARCHIVED);
+ }
+
+ public function testShopifyStatusCanBeSetToDraft(): void
+ {
+ $product = $this->_makeProduct(Product::SHOPIFY_STATUS_DRAFT);
+ self::assertEquals(Product::SHOPIFY_STATUS_DRAFT, $product->shopifyStatus);
+ }
+
+ public function testShopifyStatusCanBeSetToArchived(): void
+ {
+ $product = $this->_makeProduct(Product::SHOPIFY_STATUS_ARCHIVED);
+ self::assertEquals(Product::SHOPIFY_STATUS_ARCHIVED, $product->shopifyStatus);
+ }
+
+ // -------------------------------------------------------------------------
+ // getStatus()
+ // -------------------------------------------------------------------------
+
+ public function testGetStatusReturnsLiveWhenEnabledAndActive(): void
+ {
+ $product = $this->_makeProduct();
+ $product->enabled = true;
+ self::assertEquals(Product::STATUS_LIVE, $product->getStatus());
+ }
+
+ public function testGetStatusReturnsShopifyDraftWhenEnabledAndDraft(): void
+ {
+ $product = $this->_makeProduct(Product::SHOPIFY_STATUS_DRAFT);
+ $product->enabled = true;
+ self::assertEquals(Product::STATUS_SHOPIFY_DRAFT, $product->getStatus());
+ }
+
+ public function testGetStatusReturnsShopifyArchivedWhenEnabledAndArchived(): void
+ {
+ $product = $this->_makeProduct(Product::SHOPIFY_STATUS_ARCHIVED);
+ $product->enabled = true;
+ self::assertEquals(Product::STATUS_SHOPIFY_ARCHIVED, $product->getStatus());
+ }
+
+ public function testGetStatusReturnsDisabledWhenNotEnabled(): void
+ {
+ $product = $this->_makeProduct();
+ $product->enabled = false;
+ self::assertEquals(Product::STATUS_DISABLED, $product->getStatus());
+ }
+
+ public function testGetStatusReturnsDisabledRegardlessOfShopifyStatus(): void
+ {
+ foreach ([Product::SHOPIFY_STATUS_ACTIVE, Product::SHOPIFY_STATUS_DRAFT, Product::SHOPIFY_STATUS_ARCHIVED] as $shopifyStatus) {
+ $product = $this->_makeProduct($shopifyStatus);
+ $product->enabled = false;
+ self::assertEquals(Product::STATUS_DISABLED, $product->getStatus(), "Expected disabled status for shopifyStatus=$shopifyStatus");
+ }
+ }
+
+ // -------------------------------------------------------------------------
+ // tags
+ // -------------------------------------------------------------------------
+
+ public function testGetTagsReturnsEmptyArrayByDefault(): void
+ {
+ $product = new Product();
+ self::assertSame([], $product->getTags());
+ }
+
+ public function testSetTagsAcceptsArray(): void
+ {
+ $product = new Product();
+ $product->setTags(['sale', 'new']);
+ self::assertSame(['sale', 'new'], $product->getTags());
+ }
+
+ public function testSetTagsDecodesJsonString(): void
+ {
+ $product = new Product();
+ $product->setTags('["sale","new"]');
+ self::assertSame(['sale', 'new'], $product->getTags());
+ }
+
+ // -------------------------------------------------------------------------
+ // options
+ // -------------------------------------------------------------------------
+
+ public function testGetOptionsReturnsEmptyArrayByDefault(): void
+ {
+ $product = new Product();
+ self::assertSame([], $product->getOptions());
+ }
+
+ public function testSetOptionsAcceptsArray(): void
+ {
+ $product = new Product();
+ $options = [['name' => 'Size', 'values' => ['S', 'M', 'L']]];
+ $product->setOptions($options);
+ self::assertSame($options, $product->getOptions());
+ }
+
+ public function testSetOptionsDecodesJsonString(): void
+ {
+ $product = new Product();
+ $product->setOptions('[{"name":"Size","values":["S","M"]}]');
+ self::assertEquals('Size', $product->getOptions()[0]['name']);
+ self::assertSame(['S', 'M'], $product->getOptions()[0]['values']);
+ }
+
+ // -------------------------------------------------------------------------
+ // data
+ // -------------------------------------------------------------------------
+
+ public function testGetDataReturnsEmptyArrayByDefault(): void
+ {
+ $product = new Product();
+ self::assertSame([], $product->getData());
+ }
+
+ public function testSetDataAcceptsArray(): void
+ {
+ $product = new Product();
+ $product->setData(['title' => 'Test Product']);
+ self::assertSame(['title' => 'Test Product'], $product->getData());
+ }
+
+ public function testSetDataDecodesJsonString(): void
+ {
+ $product = new Product();
+ $product->setData('{"title":"Test Product"}');
+ self::assertEquals('Test Product', $product->getData()['title']);
+ }
+
+ public function testSetDataNullResultsInEmptyArray(): void
+ {
+ $product = new Product();
+ $product->setData(['title' => 'Test Product']);
+ $product->setData(null);
+ self::assertSame([], $product->getData());
+ }
+
+ // -------------------------------------------------------------------------
+ // descriptionHtml
+ // -------------------------------------------------------------------------
+
+ public function testGetDescriptionHtmlReturnsNullWhenNoData(): void
+ {
+ $product = new Product();
+ self::assertNull($product->getDescriptionHtml());
+ }
+
+ public function testGetDescriptionHtmlReturnsValueFromData(): void
+ {
+ $product = new Product();
+ $product->setData(['descriptionHtml' => 'Hello
']);
+ self::assertEquals('Hello
', $product->getDescriptionHtml());
+ }
+
+ // -------------------------------------------------------------------------
+ // variants
+ // -------------------------------------------------------------------------
+
+ public function testGetVariantsReturnsEmptyCollectionWithNoShopifyGid(): void
+ {
+ $product = new Product();
+ self::assertCount(0, $product->getVariants());
+ }
+
+ public function testSetVariantsWrapsPlainArrayInVariantCollection(): void
+ {
+ $product = $this->_makeProduct();
+ $product->setVariants([]);
+ self::assertInstanceOf(VariantCollection::class, $product->getVariants());
+ }
+
+ public function testSetVariantsKeepsExistingVariantCollection(): void
+ {
+ $product = $this->_makeProduct();
+ $collection = VariantCollection::make();
+ $product->setVariants($collection);
+ self::assertSame($collection, $product->getVariants());
+ }
+
+ // -------------------------------------------------------------------------
+ // images
+ // -------------------------------------------------------------------------
+
+ public function testGetImagesReturnsEmptyArrayWhenNoShopifyGid(): void
+ {
+ $product = new Product();
+ self::assertSame([], $product->getImages());
+ }
+
+ public function testSetImagesAcceptsArray(): void
+ {
+ $product = $this->_makeProduct();
+ $images = [['url' => 'https://example.com/img.jpg']];
+ $product->setImages($images);
+ self::assertSame($images, $product->getImages());
+ }
+
+ public function testSetImagesDecodesJsonString(): void
+ {
+ $product = $this->_makeProduct();
+ $product->setImages('[{"url":"https://example.com/img.jpg"}]');
+ self::assertIsArray($product->getImages());
+ self::assertCount(1, $product->getImages());
+ }
+
+ // -------------------------------------------------------------------------
+ // metafields
+ // -------------------------------------------------------------------------
+
+ public function testGetMetafieldsReturnsEmptyArrayWhenNoShopifyGid(): void
+ {
+ $product = new Product();
+ self::assertSame([], $product->getMetafields());
+ }
+
+ public function testSetMetafieldsAcceptsListArray(): void
+ {
+ $product = $this->_makeProduct();
+ $product->setMetafields([['key' => 'colour', 'value' => 'red']]);
+ self::assertSame(['colour' => 'red'], $product->getMetafields());
+ }
+
+ public function testSetMetafieldsDecodesJsonListString(): void
+ {
+ $product = $this->_makeProduct();
+ $product->setMetafields('[{"key":"colour","value":"red"}]');
+ self::assertSame(['colour' => 'red'], $product->getMetafields());
+ }
+
+ public function testSetMetafieldsNormalizesRawListFormat(): void
+ {
+ $product = $this->_makeProduct();
+ $product->setMetafields([['key' => 'colour', 'value' => 'red']]);
+ self::assertSame(['colour' => 'red'], $product->getMetafields());
+ }
+
+ public function testSetMetafieldsThrowsForAssociativeArray(): void
+ {
+ $product = new Product();
+ self::expectException(\InvalidArgumentException::class);
+ $product->setMetafields(['colour' => 'red']);
+ }
+
+ public function testSetMetafieldsThrowsForJsonEncodedAssociativeArray(): void
+ {
+ $product = new Product();
+ self::expectException(\InvalidArgumentException::class);
+ $product->setMetafields('{"colour":"red"}');
+ }
+
+ // -------------------------------------------------------------------------
+ // getCheapestVariant / getDefaultVariant
+ // -------------------------------------------------------------------------
+
+ public function testGetDefaultVariantReturnsNullWhenNoVariants(): void
+ {
+ $product = new Product();
+ self::assertNull($product->getDefaultVariant());
+ }
+
+ public function testGetCheapestVariantReturnsNullWhenNoVariants(): void
+ {
+ $product = new Product();
+ self::assertNull($product->getCheapestVariant());
+ }
+}
diff --git a/tests/unit/jobs/ProcessBulkOperationDataTest.php b/tests/unit/jobs/ProcessBulkOperationDataTest.php
index 28820f10..1b9129c1 100644
--- a/tests/unit/jobs/ProcessBulkOperationDataTest.php
+++ b/tests/unit/jobs/ProcessBulkOperationDataTest.php
@@ -79,7 +79,7 @@ public function testProcessItemCreatesShopifyDataRecordForVariant(): void
$job = $this->_makeJob();
$job->callProcessItem($json);
- $record = ShopifyData::findOne(['shopifyId' => $variantGid, 'parentId' => $productGid]);
+ $record = ShopifyData::findOne(['shopifyGid' => $variantGid, 'parentId' => $productGid]);
self::assertNotNull($record);
self::assertEquals('ProductVariant', $record->type);
self::assertEquals($productGid, $record->parentId);
@@ -107,7 +107,7 @@ public function testProcessItemUpdatesExistingShopifyDataRecord(): void
]);
$job->callProcessItem($updatedJson);
- $records = ShopifyData::find()->where(['shopifyId' => $variantGid, 'parentId' => $productGid])->all();
+ $records = ShopifyData::find()->where(['shopifyGid' => $variantGid, 'parentId' => $productGid])->all();
// Should still only be one record (updated in place)
self::assertCount(1, $records);
}
@@ -188,7 +188,7 @@ public function testProcessItemsFromRealBulkOperationJsonl(): void
// createOrUpdateProduct requires a full element save — mock it out
Plugin::getInstance()->set('products', $this->makeEmpty(Products::class, [
'createOrUpdateProduct' => fn() => true,
- 'deleteShopifyDataByShopifyId' => fn() => null,
+ 'deleteShopifyDataByShopifyGid' => fn() => null,
]));
$job = $this->_makeJob();
@@ -207,7 +207,7 @@ public function testProcessItemsFromRealBulkOperationJsonl(): void
self::assertEquals(60, $total);
// Product row
- $productRow = ShopifyData::find()->where(['shopifyId' => $productGid, 'type' => 'Product'])->one();
+ $productRow = ShopifyData::find()->where(['shopifyGid' => $productGid, 'type' => 'Product'])->one();
self::assertNotNull($productRow);
self::assertNull($productRow->parentId);
@@ -245,7 +245,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void
{
// Insert a couple of rows that should be wiped
\Yii::$app->db->createCommand()->insert(\craft\shopify\db\Table::DATA, [
- 'shopifyId' => 'gid://shopify/Product/before-clear-test-001',
+ 'shopifyGid' => 'gid://shopify/Product/before-clear-test-001',
'type' => 'Product',
'data' => json_encode(['id' => 'gid://shopify/Product/before-clear-test-001']),
'parentId' => null,
@@ -256,7 +256,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void
$service = Plugin::getInstance()->getBulkOperations();
$model = new BulkOperation();
- $model->shopifyId = self::BULK_OP_GID;
+ $model->shopifyGid = self::BULK_OP_GID;
$model->query = 'query {}';
$model->clearData = 'none';
$model->setStatus(BulkOperationStatus::Created);
@@ -271,7 +271,7 @@ public function testBeforeClearDataAllDeletesAllShopifyData(): void
public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void
{
\Yii::$app->db->createCommand()->insert(\craft\shopify\db\Table::DATA, [
- 'shopifyId' => 'gid://shopify/Product/before-none-test-001',
+ 'shopifyGid' => 'gid://shopify/Product/before-none-test-001',
'type' => 'Product',
'data' => json_encode(['id' => 'gid://shopify/Product/before-none-test-001']),
'parentId' => null,
@@ -284,7 +284,7 @@ public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void
$service = Plugin::getInstance()->getBulkOperations();
$model = new BulkOperation();
- $model->shopifyId = self::BULK_OP_GID;
+ $model->shopifyGid = self::BULK_OP_GID;
$model->query = 'query {}';
$model->clearData = 'none';
$model->setStatus(BulkOperationStatus::Created);
@@ -303,7 +303,7 @@ public function testBeforeClearDataNoneDoesNotDeleteShopifyData(): void
private function _makeJob(string $clearData = 'none'): TestableProcessBulkOperationData
{
return new TestableProcessBulkOperationData([
- 'bulkOperationShopifyId' => self::BULK_OP_GID,
+ 'bulkOperationShopifyGid' => self::BULK_OP_GID,
'dataUrl' => 'https://storage.example.com/data.jsonl',
'objectCount' => 0,
'clearData' => $clearData,
@@ -324,7 +324,7 @@ public function callProcessItem(mixed $item): void
public function callBefore(): void
{
// Call only our override, not BaseBatchedJob::before() which requires queue context
- $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId($this->bulkOperationShopifyId);
+ $bulkOperation = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid($this->bulkOperationShopifyGid);
if (!$bulkOperation) {
return;
@@ -336,7 +336,7 @@ public function callBefore(): void
if ($this->clearData === \craft\shopify\records\BulkOperation::CLEAR_DATA_ALL) {
ShopifyData::deleteAll();
} elseif ($this->clearData !== \craft\shopify\records\BulkOperation::CLEAR_DATA_NONE) {
- Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId($this->clearData);
+ Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid($this->clearData);
}
}
}
diff --git a/tests/unit/models/BulkOperationTest.php b/tests/unit/models/BulkOperationTest.php
new file mode 100644
index 00000000..5cb24220
--- /dev/null
+++ b/tests/unit/models/BulkOperationTest.php
@@ -0,0 +1,85 @@
+ ['class' => BulkOperationsFixture::class],
+ ];
+ }
+
+ private function _getFixtureOp(): BulkOperation
+ {
+ return Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid(self::FIXTURE_GID);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyGid
+ // -------------------------------------------------------------------------
+
+ public function testShopifyGidIsString(): void
+ {
+ $op = $this->_getFixtureOp();
+ self::assertIsString($op->shopifyGid);
+ }
+
+ public function testShopifyGidHasCorrectFormat(): void
+ {
+ $op = $this->_getFixtureOp();
+ self::assertStringStartsWith('gid://shopify/BulkOperation/', $op->shopifyGid);
+ }
+
+ public function testShopifyGidMatchesFixture(): void
+ {
+ $op = $this->_getFixtureOp();
+ self::assertEquals(self::FIXTURE_GID, $op->shopifyGid);
+ }
+
+ public function testShopifyGidNumericSegmentIsNumeric(): void
+ {
+ $op = $this->_getFixtureOp();
+ $lastSegment = substr($op->shopifyGid, strrpos($op->shopifyGid, '/') + 1);
+ self::assertMatchesRegularExpression('/^\d+$/', $lastSegment);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyGid set on new model
+ // -------------------------------------------------------------------------
+
+ public function testShopifyGidCanBeSetOnNewModel(): void
+ {
+ $model = new BulkOperation();
+ $model->shopifyGid = self::FIXTURE_GID;
+
+ self::assertEquals(self::FIXTURE_GID, $model->shopifyGid);
+ self::assertIsString($model->shopifyGid);
+ }
+
+ public function testShopifyGidIsNullByDefault(): void
+ {
+ $model = new BulkOperation();
+ self::assertNull($model->shopifyGid);
+ }
+}
diff --git a/tests/unit/models/VariantTest.php b/tests/unit/models/VariantTest.php
new file mode 100644
index 00000000..9002447c
--- /dev/null
+++ b/tests/unit/models/VariantTest.php
@@ -0,0 +1,123 @@
+ ['class' => ShopifyDataFixture::class],
+ ];
+ }
+
+ private function _getFirstVariant(): \craft\shopify\models\Variant
+ {
+ $products = $this->_makeMockProduct(self::PRODUCT_GID);
+ Plugin::getInstance()->getProducts()->eagerLoadVariantsForProducts([$products]);
+ return $products->variants->first();
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyGid
+ // -------------------------------------------------------------------------
+
+ public function testShopifyGidIsString(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertIsString($variant->shopifyGid);
+ }
+
+ public function testShopifyGidHasCorrectFormat(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertStringStartsWith('gid://shopify/ProductVariant/', $variant->shopifyGid);
+ }
+
+ public function testShopifyGidMatchesFixture(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertEquals(self::VARIANT_GID, $variant->shopifyGid);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyId
+ // -------------------------------------------------------------------------
+
+ public function testShopifyIdIsString(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertIsString($variant->shopifyId);
+ }
+
+ public function testShopifyIdIsNumeric(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertMatchesRegularExpression('/^\d+$/', $variant->shopifyId);
+ }
+
+ public function testShopifyIdMatchesFixture(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertEquals(self::VARIANT_ID, $variant->shopifyId);
+ }
+
+ // -------------------------------------------------------------------------
+ // shopifyId and shopifyGid relationship
+ // -------------------------------------------------------------------------
+
+ public function testShopifyIdIsLastSegmentOfGid(): void
+ {
+ $variant = $this->_getFirstVariant();
+ $lastSegment = substr($variant->shopifyGid, strrpos($variant->shopifyGid, '/') + 1);
+ self::assertEquals($lastSegment, $variant->shopifyId);
+ }
+
+ public function testShopifyGidContainsShopifyId(): void
+ {
+ $variant = $this->_getFirstVariant();
+ self::assertStringContainsString($variant->shopifyId, $variant->shopifyGid);
+ }
+
+ // -------------------------------------------------------------------------
+ // Helpers
+ // -------------------------------------------------------------------------
+
+ private function _makeMockProduct(string $gid): object
+ {
+ return new class($gid) {
+ public string $shopifyGid;
+ public ?VariantCollection $variants = null;
+
+ public function __construct(string $gid)
+ {
+ $this->shopifyGid = $gid;
+ }
+
+ public function setVariants(VariantCollection $variants): void
+ {
+ $this->variants = $variants;
+ }
+ };
+ }
+}
diff --git a/tests/unit/services/BulkOperationsTest.php b/tests/unit/services/BulkOperationsTest.php
index 5f3c4f86..7553249a 100644
--- a/tests/unit/services/BulkOperationsTest.php
+++ b/tests/unit/services/BulkOperationsTest.php
@@ -55,32 +55,48 @@ public function testGetAllBulkOperationsReturnsCollection(): void
public function testGetAllBulkOperationsContainsFixtureData(): void
{
$ops = Plugin::getInstance()->getBulkOperations()->getAllBulkOperations();
- $shopifyIds = $ops->pluck('shopifyId')->all();
+ $shopifyIds = $ops->pluck('shopifyGid')->all();
self::assertContains(self::FIXTURE_GID, $shopifyIds);
}
// -------------------------------------------------------------------------
- // getBulkOperationByShopifyId
+ // getBulkOperationByShopifyGid
// -------------------------------------------------------------------------
- public function testGetBulkOperationByShopifyIdFindsKnownRecord(): void
+ public function testGetBulkOperationByShopifyGidFindsKnownRecord(): void
{
- $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId(self::FIXTURE_GID);
+ $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid(self::FIXTURE_GID);
self::assertNotNull($op);
self::assertInstanceOf(BulkOperation::class, $op);
- self::assertEquals(self::FIXTURE_GID, $op->shopifyId);
+ self::assertEquals(self::FIXTURE_GID, $op->shopifyGid);
self::assertEquals(BulkOperationStatus::Completed, $op->getStatus());
}
- public function testGetBulkOperationByShopifyIdReturnsNullForUnknownId(): void
+ public function testGetBulkOperationByShopifyGidReturnsNullForUnknownId(): void
{
- $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId('gid://shopify/BulkOperation/does-not-exist');
+ $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/does-not-exist');
self::assertNull($op);
}
+ // -------------------------------------------------------------------------
+ // getBulkOperationByShopifyId (deprecated)
+ // -------------------------------------------------------------------------
+
+ /**
+ * @deprecated in 8.0.0. Use [[testGetBulkOperationByShopifyGidFindsKnownRecord()]] instead.
+ */
+ public function testGetBulkOperationByShopifyIdDelegatesToGidMethod(): void
+ {
+ $op = Plugin::getInstance()->getBulkOperations()->getBulkOperationByShopifyId(self::FIXTURE_GID);
+
+ self::assertNotNull($op);
+ self::assertInstanceOf(BulkOperation::class, $op);
+ self::assertEquals(self::FIXTURE_GID, $op->shopifyGid);
+ }
+
// -------------------------------------------------------------------------
// saveBulkOperation
// -------------------------------------------------------------------------
@@ -98,12 +114,12 @@ public function testSaveBulkOperationCreatesNewRecord(): void
public function testSaveBulkOperationUpdatesExistingRecord(): void
{
$service = Plugin::getInstance()->getBulkOperations();
- $op = $service->getBulkOperationByShopifyId(self::FIXTURE_GID);
+ $op = $service->getBulkOperationByShopifyGid(self::FIXTURE_GID);
$op->objectCount = 9999;
$service->saveBulkOperation($op, false);
- $reloaded = $service->getBulkOperationByShopifyId(self::FIXTURE_GID);
+ $reloaded = $service->getBulkOperationByShopifyGid(self::FIXTURE_GID);
self::assertEquals(9999, $reloaded->objectCount);
}
@@ -121,7 +137,7 @@ public function testDeleteBulkOperationByIdRemovesRecord(): void
$result = $service->deleteBulkOperationById($id);
self::assertTrue($result);
- self::assertNull($service->getBulkOperationByShopifyId('gid://shopify/BulkOperation/delete-test-001'));
+ self::assertNull($service->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/delete-test-001'));
}
public function testDeleteBulkOperationByIdReturnsTrueForMissingRecord(): void
@@ -140,7 +156,7 @@ public function testCannotDeleteProcessingBulkOperation(): void
$result = $service->deleteBulkOperationById($model->id);
self::assertFalse($result);
- self::assertNotNull($service->getBulkOperationByShopifyId('gid://shopify/BulkOperation/processing-test-001'));
+ self::assertNotNull($service->getBulkOperationByShopifyGid('gid://shopify/BulkOperation/processing-test-001'));
}
// -------------------------------------------------------------------------
@@ -180,7 +196,7 @@ public function testHandleBulkOperationFinishedMarksCanceledAsCompleted(): void
$service->handleBulkOperationFinished(['admin_graphql_api_id' => $gid]);
- $reloaded = $service->getBulkOperationByShopifyId($gid);
+ $reloaded = $service->getBulkOperationByShopifyGid($gid);
self::assertNotNull($reloaded);
self::assertEquals(BulkOperationStatus::Completed, $reloaded->getStatus());
self::assertEquals('CANCELED', $reloaded->shopifyStatus);
@@ -207,7 +223,7 @@ public function testHandleBulkOperationFinishedStoresUrlAndObjectCountWhenComple
$service->handleBulkOperationFinished(['admin_graphql_api_id' => $gid]);
- $reloaded = $service->getBulkOperationByShopifyId($gid);
+ $reloaded = $service->getBulkOperationByShopifyGid($gid);
self::assertNotNull($reloaded);
self::assertEquals('COMPLETED', $reloaded->shopifyStatus);
self::assertEquals($dataUrl, $reloaded->url);
@@ -241,19 +257,19 @@ public function testQueueNextBulkOperationReturnsFalseWhenAlreadyProcessing(): v
// Helpers
// -------------------------------------------------------------------------
- private function _makeQueuedOp(string $shopifyId): BulkOperation
+ private function _makeQueuedOp(string $gid): BulkOperation
{
$model = new BulkOperation();
- $model->shopifyId = $shopifyId;
+ $model->shopifyGid = $gid;
$model->query = 'query { products { edges { node { id } } } }';
$model->clearData = 'none';
$model->setStatus(BulkOperationStatus::Queued);
return $model;
}
- private function _makeCreatedOp(string $shopifyId): BulkOperation
+ private function _makeCreatedOp(string $gid): BulkOperation
{
- $model = $this->_makeQueuedOp($shopifyId);
+ $model = $this->_makeQueuedOp($gid);
$model->setStatus(BulkOperationStatus::Created);
return $model;
}
diff --git a/tests/unit/services/ProductsTest.php b/tests/unit/services/ProductsTest.php
index bceb97a8..60e898b6 100644
--- a/tests/unit/services/ProductsTest.php
+++ b/tests/unit/services/ProductsTest.php
@@ -10,6 +10,7 @@
use Codeception\Test\Unit;
use craft\shopify\collections\VariantCollection;
use craft\shopify\db\Table;
+use craft\shopify\helpers\Metafield as MetafieldHelper;
use craft\shopify\Plugin;
use craft\shopify\records\ShopifyData;
use craft\shopify\tests\fixtures\ShopifyDataFixture;
@@ -63,13 +64,13 @@ public function testNormalizeShopifyGidDoesNotDoublePrefix(): void
}
// -------------------------------------------------------------------------
- // deleteShopifyDataByShopifyId
+ // deleteShopifyDataByShopifyGid
// -------------------------------------------------------------------------
- public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): void
+ public function testDeleteShopifyDataByShopifyGidRemovesProductAndChildren(): void
{
// Verify fixture data exists before deletion
- $productRow = ShopifyData::find()->where(['shopifyId' => self::PRODUCT_GID, 'type' => 'Product'])->one();
+ $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one();
self::assertNotNull($productRow, 'Fixture product row must exist before deletion test.');
$variantsBefore = ShopifyData::find()
@@ -77,10 +78,10 @@ public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): voi
->count();
self::assertGreaterThan(0, $variantsBefore, 'Fixture must have at least one variant.');
- Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId(self::PRODUCT_GID);
+ Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid(self::PRODUCT_GID);
// Product row should be gone
- $productRow = ShopifyData::find()->where(['shopifyId' => self::PRODUCT_GID, 'type' => 'Product'])->one();
+ $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one();
self::assertNull($productRow);
// All direct children (variants, images) should also be gone
@@ -88,13 +89,31 @@ public function testDeleteShopifyDataByShopifyIdRemovesProductAndChildren(): voi
self::assertEquals(0, $children);
}
- public function testDeleteShopifyDataByShopifyIdAcceptsNumericId(): void
+ public function testDeleteShopifyDataByShopifyGidAcceptsNumericId(): void
{
// Numeric ID should be normalized to GID before lookup — no exception expected
- Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId('7136060145715');
+ Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyGid('7136060145715');
$this->assertTrue(true);
}
+ // -------------------------------------------------------------------------
+ // deleteShopifyDataByShopifyId (deprecated)
+ // -------------------------------------------------------------------------
+
+ /**
+ * @deprecated in 8.0.0. Use [[testDeleteShopifyDataByShopifyGidRemovesProductAndChildren()]] instead.
+ */
+ public function testDeleteShopifyDataByShopifyIdDelegatesToGidMethod(): void
+ {
+ $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one();
+ self::assertNotNull($productRow, 'Fixture product row must exist before deletion test.');
+
+ Plugin::getInstance()->getProducts()->deleteShopifyDataByShopifyId(self::PRODUCT_GID);
+
+ $productRow = ShopifyData::find()->where(['shopifyGid' => self::PRODUCT_GID, 'type' => 'Product'])->one();
+ self::assertNull($productRow);
+ }
+
// -------------------------------------------------------------------------
// eagerLoadVariantsForProducts
// -------------------------------------------------------------------------
@@ -164,7 +183,7 @@ public function testEagerLoadMetafieldsForProductsMapsKeyValuePairs(): void
// Insert a product row and a metafield child
\Yii::$app->db->createCommand()->insert(Table::DATA, [
- 'shopifyId' => $productGid,
+ 'shopifyGid' => $productGid,
'type' => 'Product',
'data' => json_encode(['id' => $productGid, 'title' => 'Test']),
'parentId' => null,
@@ -174,7 +193,7 @@ public function testEagerLoadMetafieldsForProductsMapsKeyValuePairs(): void
])->execute();
\Yii::$app->db->createCommand()->insert(Table::DATA, [
- 'shopifyId' => 'gid://shopify/Metafield/test-mf-1',
+ 'shopifyGid' => 'gid://shopify/Metafield/test-mf-1',
'type' => 'Metafield',
'data' => json_encode(['id' => 'gid://shopify/Metafield/test-mf-1', 'key' => 'my_key', 'value' => 'my_value']),
'parentId' => $productGid,
@@ -224,7 +243,7 @@ public function setImages(array $images): void
public function setMetafields(array $metafields): void
{
- $this->metafields = $metafields;
+ $this->metafields = MetafieldHelper::normalizeToMap($metafields);
}
};
}, $shopifyGids);