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);