diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5e8c794175..084e91b582 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: - '5.x' + - '5.6' pull_request: permissions: contents: read diff --git a/CHANGELOG-WIP.md b/CHANGELOG-WIP.md new file mode 100644 index 0000000000..cc9508c442 --- /dev/null +++ b/CHANGELOG-WIP.md @@ -0,0 +1,27 @@ +# WIP Release notes for Commerce 5.6 + +### Development +- Cart controller actions that accept an explicit cart number are now rate limited to mitigate enumeration attacks. +- Cart numbers are now generated using a cryptographically secure random number generator. +- Shipping rule categories are now eager loaded on shipping rules automatically. ([#4220](https://github.com/craftcms/commerce/issues/4220)) + +### Extensibility +- Added `craft\commerce\elements\db\ProductQuery::$savable`. +- Added `craft\commerce\elements\db\ProductQuery::savable()`. +- Added `craft\commerce\elements\db\VariantQuery::$savable`. +- Added `craft\commerce\elements\db\VariantQuery::editable()`. +- Added `craft\commerce\elements\db\VariantQuery::savable()`. +- Added `craft\commerce\helpers\ProductQuery::cleanseQueryCriteria()`. +- Added `craft\commerce\services\ShippingRuleCategories::getShippingRuleCategoriesByRuleIds()`. +- Added `craft\commerce\services\ShippingRuleCategories::getShippingRuleCategoriesByRuleIds()`. +- Added `relatedToProducts` and `relatedToVariants` GraphQL query arguments, enabling queries for elements related to specific products or variants. ([#4202](https://github.com/craftcms/commerce/discussions/4202)) +- Added `variantUiLabelFormat` and `productUiLabelFormat` settings to product types, for customizing how products and variants are labeled throughout the control panel. ([#4178](https://github.com/craftcms/commerce/pull/4178)) +- `craft\commerce\elements\db\ProductQuery::$editable` is now nullable. +- `craft\commerce\elements\db\VariantQuery::$editable` is now nullable. + +### System +- Craft Commerce now requires Craft CMS 5.9.9 or later. +- Fixed a bug where Variant with empty SKUs didn't show a validation error when saving a product after it was duplicated. ([#4197](https://github.com/craftcms/commerce/issues/4197)) +- Fixed a SQL error that could occur when querying for unfulfilled orders on PostgreSQL. ([#4228](https://github.com/craftcms/commerce/issues/4228)) +- Fixed an error that could occur when resaving variants. ([#4226](https://github.com/craftcms/commerce/issues/4226)) +- Fixed [high-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) SQL injection vulnerabilities in the control panel. diff --git a/CHANGELOG.md b/CHANGELOG.md index 2612670789..39ea08c8ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Fixed an error that could occur when editing inventory locations. ([#4233](https://github.com/craftcms/commerce/issues/4233)) - Fixed a SQL error that could occur when querying for unfulfilled orders on PostgreSQL. ([#4228](https://github.com/craftcms/commerce/issues/4228)) - Fixed an error that could occur when resaving variants. ([#4226](https://github.com/craftcms/commerce/issues/4226)) +- Fixed [high-severity](https://github.com/craftcms/cms/security/policy#severity--remediation) SQL injection vulnerabilities in the control panel. +- Added `craft\commerce\helpers\ProductQuery::cleanseQueryCriteria()`. ## 5.5.3 - 2026-02-09 diff --git a/composer.json b/composer.json index 424ae5b760..1675852421 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,7 @@ "prefer-stable": true, "require": { "php": "^8.2", - "craftcms/cms": "^5.6.0", + "craftcms/cms": "^5.9.9", "dompdf/dompdf": "^2.0.2", "ibericode/vat": "^1.2.2", "iio/libmergepdf": "^4.0", diff --git a/composer.lock b/composer.lock index 308915efeb..5d7460400e 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": "ff78ccead3f15c9a900dafb12295cbee", + "content-hash": "8e3d9e9078db15f2d323c4001d063d0f", "packages": [ { "name": "bacon/bacon-qr-code", @@ -62,16 +62,16 @@ }, { "name": "brick/math", - "version": "0.14.6", + "version": "0.14.8", "source": { "type": "git", "url": "https://github.com/brick/math.git", - "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3" + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/brick/math/zipball/32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", - "reference": "32498d5e1897e7642c0b961ace2df6d7dc9a3bc3", + "url": "https://api.github.com/repos/brick/math/zipball/63422359a44b7f06cae63c3b429b59e8efcc0629", + "reference": "63422359a44b7f06cae63c3b429b59e8efcc0629", "shasum": "" }, "require": { @@ -110,7 +110,7 @@ ], "support": { "issues": "https://github.com/brick/math/issues", - "source": "https://github.com/brick/math/tree/0.14.6" + "source": "https://github.com/brick/math/tree/0.14.8" }, "funding": [ { @@ -118,7 +118,7 @@ "type": "github" } ], - "time": "2026-02-05T07:59:58+00:00" + "time": "2026-02-10T14:33:43+00:00" }, { "name": "carbonphp/carbon-doctrine-types", @@ -475,16 +475,16 @@ }, { "name": "craftcms/cms", - "version": "5.9.6", + "version": "5.9.10", "source": { "type": "git", "url": "https://github.com/craftcms/cms.git", - "reference": "558372701d7870da4a3cda88592a21d962de0cb1" + "reference": "2f5149d4d64a5dafae9db99357823655422ba77d" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/craftcms/cms/zipball/558372701d7870da4a3cda88592a21d962de0cb1", - "reference": "558372701d7870da4a3cda88592a21d962de0cb1", + "url": "https://api.github.com/repos/craftcms/cms/zipball/2f5149d4d64a5dafae9db99357823655422ba77d", + "reference": "2f5149d4d64a5dafae9db99357823655422ba77d", "shasum": "" }, "require": { @@ -601,7 +601,7 @@ "rss": "https://github.com/craftcms/cms/releases.atom", "source": "https://github.com/craftcms/cms" }, - "time": "2026-02-03T16:48:37+00:00" + "time": "2026-02-13T00:01:18+00:00" }, { "name": "craftcms/plugin-installer", @@ -880,29 +880,29 @@ }, { "name": "doctrine/deprecations", - "version": "1.1.5", + "version": "1.1.6", "source": { "type": "git", "url": "https://github.com/doctrine/deprecations.git", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38" + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/doctrine/deprecations/zipball/459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", - "reference": "459c2f5dd3d6a4633d3b5f46ee2b1c40f57d3f38", + "url": "https://api.github.com/repos/doctrine/deprecations/zipball/d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", + "reference": "d4fe3e6fd9bb9e72557a19674f44d8ac7db4c6ca", "shasum": "" }, "require": { "php": "^7.1 || ^8.0" }, "conflict": { - "phpunit/phpunit": "<=7.5 || >=13" + "phpunit/phpunit": "<=7.5 || >=14" }, "require-dev": { - "doctrine/coding-standard": "^9 || ^12 || ^13", - "phpstan/phpstan": "1.4.10 || 2.1.11", + "doctrine/coding-standard": "^9 || ^12 || ^14", + "phpstan/phpstan": "1.4.10 || 2.1.30", "phpstan/phpstan-phpunit": "^1.0 || ^2", - "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12", + "phpunit/phpunit": "^7.5 || ^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.4 || ^13.0", "psr/log": "^1 || ^2 || ^3" }, "suggest": { @@ -922,9 +922,9 @@ "homepage": "https://www.doctrine-project.org/", "support": { "issues": "https://github.com/doctrine/deprecations/issues", - "source": "https://github.com/doctrine/deprecations/tree/1.1.5" + "source": "https://github.com/doctrine/deprecations/tree/1.1.6" }, - "time": "2025-04-07T20:06:18+00:00" + "time": "2026-02-07T07:09:04+00:00" }, { "name": "doctrine/inflector", @@ -7590,16 +7590,16 @@ }, { "name": "webmozart/assert", - "version": "2.1.2", + "version": "2.1.3", "source": { "type": "git", "url": "https://github.com/webmozarts/assert.git", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649" + "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", - "reference": "ce6a2f100c404b2d32a1dd1270f9b59ad4f57649", + "url": "https://api.github.com/repos/webmozarts/assert/zipball/6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", + "reference": "6976757ba8dd70bf8cbaea0914ad84d8b51a9f46", "shasum": "" }, "require": { @@ -7646,9 +7646,9 @@ ], "support": { "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.1.2" + "source": "https://github.com/webmozarts/assert/tree/2.1.3" }, - "time": "2026-01-13T14:02:24+00:00" + "time": "2026-02-13T21:01:40+00:00" }, { "name": "webonyx/graphql-php", @@ -8419,22 +8419,22 @@ }, { "name": "codeception/lib-asserts", - "version": "3.1.0", + "version": "3.2.0", "source": { "type": "git", "url": "https://github.com/Codeception/lib-asserts.git", - "reference": "8e161f38a71cdf3dc638c5427df21c0f01f12d13" + "reference": "f161e5d3a9e5ae573ca01cfb3b5601ff5303df03" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/8e161f38a71cdf3dc638c5427df21c0f01f12d13", - "reference": "8e161f38a71cdf3dc638c5427df21c0f01f12d13", + "url": "https://api.github.com/repos/Codeception/lib-asserts/zipball/f161e5d3a9e5ae573ca01cfb3b5601ff5303df03", + "reference": "f161e5d3a9e5ae573ca01cfb3b5601ff5303df03", "shasum": "" }, "require": { "ext-dom": "*", "php": "^8.2 || ^8.3 || ^8.4 || ^8.5", - "phpunit/phpunit": "^11.5 || ^12.0" + "phpunit/phpunit": "^11.5 || ^12.0 || ^13.0" }, "type": "library", "autoload": { @@ -8467,9 +8467,9 @@ ], "support": { "issues": "https://github.com/Codeception/lib-asserts/issues", - "source": "https://github.com/Codeception/lib-asserts/tree/3.1.0" + "source": "https://github.com/Codeception/lib-asserts/tree/3.2.0" }, - "time": "2025-12-22T08:25:07+00:00" + "time": "2026-02-06T15:19:32+00:00" }, { "name": "codeception/lib-innerbrowser", @@ -8532,23 +8532,23 @@ }, { "name": "codeception/lib-web", - "version": "2.0.1", + "version": "2.1.0", "source": { "type": "git", "url": "https://github.com/Codeception/lib-web.git", - "reference": "bbec12e789c3b810ec8cb86e5f46b5bfd673c441" + "reference": "a030a3a22fc8e856b5957086794ed5403c7992d9" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/lib-web/zipball/bbec12e789c3b810ec8cb86e5f46b5bfd673c441", - "reference": "bbec12e789c3b810ec8cb86e5f46b5bfd673c441", + "url": "https://api.github.com/repos/Codeception/lib-web/zipball/a030a3a22fc8e856b5957086794ed5403c7992d9", + "reference": "a030a3a22fc8e856b5957086794ed5403c7992d9", "shasum": "" }, "require": { "ext-mbstring": "*", "guzzlehttp/psr7": "^2.0", "php": "^8.2", - "phpunit/phpunit": "^11.5 | ^12", + "phpunit/phpunit": "^11.5 | ^12 | ^13", "symfony/css-selector": ">=4.4.24 <9.0" }, "conflict": { @@ -8579,9 +8579,9 @@ ], "support": { "issues": "https://github.com/Codeception/lib-web/issues", - "source": "https://github.com/Codeception/lib-web/tree/2.0.1" + "source": "https://github.com/Codeception/lib-web/tree/2.1.0" }, - "time": "2025-11-27T21:09:09+00:00" + "time": "2026-02-06T15:22:13+00:00" }, { "name": "codeception/lib-xml", @@ -8925,27 +8925,27 @@ }, { "name": "codeception/stub", - "version": "4.2.1", + "version": "4.3.0", "source": { "type": "git", "url": "https://github.com/Codeception/Stub.git", - "reference": "0c573cd5c62a828dadadc41bc56f8434860bb7bb" + "reference": "6305b97eaf6ea9bdaed29a5bd4d6f2948f577d8f" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Codeception/Stub/zipball/0c573cd5c62a828dadadc41bc56f8434860bb7bb", - "reference": "0c573cd5c62a828dadadc41bc56f8434860bb7bb", + "url": "https://api.github.com/repos/Codeception/Stub/zipball/6305b97eaf6ea9bdaed29a5bd4d6f2948f577d8f", + "reference": "6305b97eaf6ea9bdaed29a5bd4d6f2948f577d8f", "shasum": "" }, "require": { "php": "^8.1", - "phpunit/phpunit": "^8.4 | ^9.0 | ^10.0 | ^11 | ^12" + "phpunit/phpunit": "^8.4 | ^9.0 | ^10.0 | ^11 | ^12 | ^13" }, "conflict": { "codeception/codeception": "<5.0.6" }, "require-dev": { - "consolidation/robo": "^3.0" + "consolidation/robo": "^4.0" }, "type": "library", "autoload": { @@ -8960,9 +8960,9 @@ "description": "Flexible Stub wrapper for PHPUnit's Mock Builder", "support": { "issues": "https://github.com/Codeception/Stub/issues", - "source": "https://github.com/Codeception/Stub/tree/4.2.1" + "source": "https://github.com/Codeception/Stub/tree/4.3.0" }, - "time": "2025-12-05T13:37:14+00:00" + "time": "2026-02-06T15:19:04+00:00" }, { "name": "composer/ca-bundle", @@ -9531,16 +9531,16 @@ }, { "name": "justinrainbow/json-schema", - "version": "6.6.4", + "version": "v6.7.2", "source": { "type": "git", "url": "https://github.com/jsonrainbow/json-schema.git", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7" + "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/2eeb75d21cf73211335888e7f5e6fd7440723ec7", - "reference": "2eeb75d21cf73211335888e7f5e6fd7440723ec7", + "url": "https://api.github.com/repos/jsonrainbow/json-schema/zipball/6fea66c7204683af437864e7c4e7abf383d14bc0", + "reference": "6fea66c7204683af437864e7c4e7abf383d14bc0", "shasum": "" }, "require": { @@ -9600,9 +9600,9 @@ ], "support": { "issues": "https://github.com/jsonrainbow/json-schema/issues", - "source": "https://github.com/jsonrainbow/json-schema/tree/6.6.4" + "source": "https://github.com/jsonrainbow/json-schema/tree/v6.7.2" }, - "time": "2025-12-19T15:01:32+00:00" + "time": "2026-02-15T15:06:22+00:00" }, { "name": "league/factory-muffin", @@ -10847,16 +10847,16 @@ }, { "name": "phpunit/phpunit", - "version": "11.5.51", + "version": "11.5.53", "source": { "type": "git", "url": "https://github.com/sebastianbergmann/phpunit.git", - "reference": "ad14159f92910b0f0e3928c13e9b2077529de091" + "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/ad14159f92910b0f0e3928c13e9b2077529de091", - "reference": "ad14159f92910b0f0e3928c13e9b2077529de091", + "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/a997a653a82845f1240d73ee73a8a4e97e4b0607", + "reference": "a997a653a82845f1240d73ee73a8a4e97e4b0607", "shasum": "" }, "require": { @@ -10929,7 +10929,7 @@ "support": { "issues": "https://github.com/sebastianbergmann/phpunit/issues", "security": "https://github.com/sebastianbergmann/phpunit/security/policy", - "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.51" + "source": "https://github.com/sebastianbergmann/phpunit/tree/11.5.53" }, "funding": [ { @@ -10953,20 +10953,20 @@ "type": "tidelift" } ], - "time": "2026-02-05T07:59:30+00:00" + "time": "2026-02-10T12:28:25+00:00" }, { "name": "psy/psysh", - "version": "v0.12.19", + "version": "v0.12.20", "source": { "type": "git", "url": "https://github.com/bobthecow/psysh.git", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee" + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/bobthecow/psysh/zipball/a4f766e5c5b6773d8399711019bb7d90875a50ee", - "reference": "a4f766e5c5b6773d8399711019bb7d90875a50ee", + "url": "https://api.github.com/repos/bobthecow/psysh/zipball/19678eb6b952a03b8a1d96ecee9edba518bb0373", + "reference": "19678eb6b952a03b8a1d96ecee9edba518bb0373", "shasum": "" }, "require": { @@ -11030,9 +11030,9 @@ ], "support": { "issues": "https://github.com/bobthecow/psysh/issues", - "source": "https://github.com/bobthecow/psysh/tree/v0.12.19" + "source": "https://github.com/bobthecow/psysh/tree/v0.12.20" }, - "time": "2026-01-30T17:33:13+00:00" + "time": "2026-02-11T15:05:28+00:00" }, { "name": "rector/rector", @@ -12642,5 +12642,5 @@ "php": "^8.2" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/src/Plugin.php b/src/Plugin.php index e8a60bf311..cc41cd36d6 100755 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -44,10 +44,14 @@ use craft\commerce\fieldlayoutelements\VariantTitleField; use craft\commerce\fields\Products as ProductsField; use craft\commerce\fields\Variants as VariantsField; +use craft\commerce\gql\handlers\RelatedProducts; +use craft\commerce\gql\handlers\RelatedVariants; use craft\commerce\gql\interfaces\elements\Product as GqlProductInterface; use craft\commerce\gql\interfaces\elements\Variant as GqlVariantInterface; use craft\commerce\gql\queries\Product as GqlProductQueries; use craft\commerce\gql\queries\Variant as GqlVariantQueries; +use craft\commerce\gql\types\input\criteria\ProductRelation; +use craft\commerce\gql\types\input\criteria\VariantRelation; use craft\commerce\helpers\ProjectConfigData; use craft\commerce\linktypes\Product as ProductLinkType; use craft\commerce\migrations\Install; @@ -137,6 +141,7 @@ use craft\events\RegisterComponentTypesEvent; use craft\events\RegisterElementExportersEvent; use craft\events\RegisterEmailMessagesEvent; +use craft\events\RegisterGqlArgumentHandlersEvent; use craft\events\RegisterGqlEagerLoadableFields; use craft\events\RegisterGqlQueriesEvent; use craft\events\RegisterGqlSchemaComponentsEvent; @@ -144,6 +149,7 @@ use craft\events\RegisterUserPermissionsEvent; use craft\fields\Link; use craft\fixfks\controllers\RestoreController; +use craft\gql\ArgumentManager; use craft\gql\ElementQueryConditionBuilder; use craft\helpers\ArrayHelper; use craft\helpers\Console; @@ -259,7 +265,7 @@ public static function editions(): array /** * @inheritDoc */ - public string $schemaVersion = '5.5.0.5'; + public string $schemaVersion = '5.6.0.0'; /** * @inheritdoc @@ -311,6 +317,7 @@ public function init(): void $this->_registerGqlQueries(); $this->_registerGqlComponents(); $this->_registerGqlEagerLoadableFields(); + $this->_registerGqlArgumentHandlers(); $this->_registerLinkTypes(); $this->_registerCacheTypes(); $this->_registerGarbageCollection(); @@ -1029,6 +1036,41 @@ private function _registerGqlEagerLoadableFields(): void }); } + /** + * Register the Gql argument handlers + * + * @since 5.6.0 + */ + private function _registerGqlArgumentHandlers(): void + { + Event::on(ArgumentManager::class, ArgumentManager::EVENT_DEFINE_GQL_ARGUMENT_HANDLERS, static function(RegisterGqlArgumentHandlersEvent $event) { + $event->handlers['relatedToProducts'] = RelatedProducts::class; + $event->handlers['relatedToVariants'] = RelatedVariants::class; + }); + + // Add relatedToProducts and relatedToVariants arguments to element queries + Event::on(Gql::class, Gql::EVENT_REGISTER_GQL_QUERIES, static function(RegisterGqlQueriesEvent $event) { + $relatedToProductsArg = [ + 'name' => 'relatedToProducts', + 'type' => \GraphQL\Type\Definition\Type::listOf(ProductRelation::getType()), + 'description' => 'Narrows the query results to elements that relate to a product list defined with this argument.', + ]; + $relatedToVariantsArg = [ + 'name' => 'relatedToVariants', + 'type' => \GraphQL\Type\Definition\Type::listOf(VariantRelation::getType()), + 'description' => 'Narrows the query results to elements that relate to a variant list defined with this argument.', + ]; + + // Add the arguments to all relevant queries + foreach ($event->queries as $queryName => &$queryConfig) { + if (isset($queryConfig['args']) && is_array($queryConfig['args'])) { + $queryConfig['args']['relatedToProducts'] = $relatedToProductsArg; + $queryConfig['args']['relatedToVariants'] = $relatedToVariantsArg; + } + } + }); + } + /** * Register the cache types */ diff --git a/src/controllers/CartController.php b/src/controllers/CartController.php index 43aaf18298..cc86b1da4c 100644 --- a/src/controllers/CartController.php +++ b/src/controllers/CartController.php @@ -19,10 +19,13 @@ use craft\elements\User; use craft\errors\ElementNotFoundException; use craft\errors\MissingComponentException; -use craft\helpers\Json; use craft\helpers\StringHelper; use craft\helpers\UrlHelper; use Illuminate\Support\Collection; +use thamtech\ratelimiter\Context; +use thamtech\ratelimiter\handlers\TooManyRequestsHttpExceptionHandler; +use thamtech\ratelimiter\limit\RateLimit; +use thamtech\ratelimiter\RateLimiter; use Throwable; use yii\base\Exception; use yii\base\InvalidConfigException; @@ -76,6 +79,41 @@ public function init(): void parent::init(); } + /** + * @inheritdoc + */ + public function behaviors(): array + { + return parent::behaviors() + [ + 'rateLimiter' => [ + 'class' => RateLimiter::class, + 'only' => ['get-cart', 'update-cart', 'load-cart', 'complete'], + 'components' => [ + 'rateLimit' => [ + 'definitions' => [ + 'cart-by-number' => [ + 'class' => RateLimit::class, + 'limit' => 1, + 'window' => 1, + // Only apply rate limiting when a cart number is explicitly passed + 'active' => fn(Context $context, $rateLimitId) => $context->request->getBodyParam('number') || $context->request->getQueryParam('number'), + 'identifier' => fn(Context $context, $rateLimitId) => sprintf( + '%s:%s', + $rateLimitId, + $context->request->getUserIP(), + ), + ], + ], + ], + 'allowanceStorage' => [ + 'cache' => 'cache', + ], + ], + 'as tooManyRequestsException' => TooManyRequestsHttpExceptionHandler::class, + ], + ]; + } + /** * Returns the cart as JSON * diff --git a/src/elements/Product.php b/src/elements/Product.php index 72693c54fa..f8fc3f8799 100644 --- a/src/elements/Product.php +++ b/src/elements/Product.php @@ -226,7 +226,7 @@ protected static function defineSources(string $context = null): array $editable = true; } else { $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); - $editable = false; + $editable = null; } $productTypeIds = []; @@ -404,7 +404,10 @@ protected static function defineActions(string $source = null): array if ($canCreate) { // Duplicate - $actions[] = Duplicate::class; + $actions[] = [ + 'type' => Duplicate::class, + 'asDrafts' => true, + ]; } if ($canDelete) { @@ -425,6 +428,14 @@ protected static function defineActions(string $source = null): array $productType->isStructure && $canCreate ) { + if ($productType->maxLevels != 1) { + $actions[] = [ + 'type' => Duplicate::class, + 'asDrafts' => true, + 'deep' => true, + ]; + } + $newProductUrl = 'commerce/products/' . $productType->handle . '/new'; if (Craft::$app->getIsMultiSite()) { @@ -1054,6 +1065,14 @@ protected function crumbs(): array */ protected function uiLabel(): ?string { + $uiLabelFormat = $this->getType()->productUiLabelFormat; + if ($uiLabelFormat !== '{title}') { + $uiLabel = Craft::$app->getView()->renderObjectTemplate($uiLabelFormat, $this); + if ($uiLabel !== '') { + return $uiLabel; + } + } + if (!isset($this->title) || trim($this->title) === '') { return Craft::t('app', 'Untitled {type}', [ 'type' => self::lowerDisplayName(), @@ -1204,18 +1223,6 @@ public function getVariants(bool $includeDisabled = false): VariantCollection return $this->_variants->filter(fn(Variant $variant) => $includeDisabled || ($variant->getStatus() === self::STATUS_ENABLED)); } - /** - * @return VariantCollection - * @throws InvalidConfigException - * @internal Do not use. Temporary method until we get a nested element manager provider in core. - * - * TODO: Remove this once we have a nested element manager provider interface in core. - */ - public function getAllVariants(): VariantCollection - { - return $this->getVariants(true); - } - /** * @inheritdoc */ @@ -1842,6 +1849,18 @@ function() { }, 'on' => self::SCENARIO_LIVE, ], + [ + ['variants'], + function() { + foreach ($this->getVariants(true) as $variant) { + if (!$variant->sku || PurchasableHelper::isTempSku($variant->sku)) { + $this->addError('variants', Craft::t('commerce', 'All variants must have a SKU.')); + break; + } + } + }, + 'on' => self::SCENARIO_LIVE, + ], [ ['variants'], function() { diff --git a/src/elements/Variant.php b/src/elements/Variant.php index 887b08869b..d1c7c57c8e 100755 --- a/src/elements/Variant.php +++ b/src/elements/Variant.php @@ -256,6 +256,25 @@ public function init(): void $this->ownerType = Product::class; } + /** + * @inheritdoc + */ + protected function uiLabel(): ?string + { + $owner = $this->getOwner(); + if ($owner) { + $uiLabelFormat = $owner->getType()->variantUiLabelFormat; + if ($uiLabelFormat !== '{title}') { + $uiLabel = Craft::$app->getView()->renderObjectTemplate($uiLabelFormat, $this); + if ($uiLabel !== '') { + return $uiLabel; + } + } + } + + return null; + } + /** * @inheritdoc */ diff --git a/src/elements/db/ProductQuery.php b/src/elements/db/ProductQuery.php index c821df1847..168c9c7767 100644 --- a/src/elements/db/ProductQuery.php +++ b/src/elements/db/ProductQuery.php @@ -52,9 +52,17 @@ class ProductQuery extends ElementQuery { /** - * @var bool Whether to only return products that the user has permission to edit. + * @var bool|null Whether to only return products that the user has permission to view. + * @used-by editable() */ - public bool $editable = false; + public ?bool $editable = null; + + /** + * @var bool|null Whether to only return products that the user has permission to save. + * @used-by savable() + * @since 5.6.0 + */ + public ?bool $savable = null; /** * @var mixed The Post Date that the resulting products must have. @@ -529,17 +537,32 @@ public function after(DateTime|string $value): static } /** - * Sets the [[editable]] property. + * Sets the [[$editable]] property. * - * @param bool $value The property value (defaults to true) + * @param bool|null $value The property value (defaults to true) * @return static self reference + * @uses $editable */ - public function editable(bool $value = true): static + public function editable(?bool $value = true): static { $this->editable = $value; return $this; } + /** + * Sets the [[$savable]] property. + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference + * @uses $savable + * @since 5.6.0 + */ + public function savable(?bool $value = true): static + { + $this->savable = $value; + return $this; + } + /** * Narrows the query results based on the products’ types, per the types’ IDs. * @@ -825,7 +848,8 @@ protected function beforePrepare(): bool } $this->_applyHasVariantParam(); - $this->_applyEditableParam(); + $this->_applyEditableParam($this->editable, 'commerce-editProductType'); + $this->_applyEditableParam($this->savable, 'commerce-editProductType'); $this->_applyRefParam(); return parent::beforePrepare(); @@ -858,26 +882,61 @@ private function _normalizeTypeId(): void } /** - * Applies the 'editable' param to the query being prepared. + * Applies an authorization param to the query being prepared. * + * @param bool|null $value + * @param string $permissionPrefix * @throws QueryAbortedException */ - private function _applyEditableParam(): void + private function _applyEditableParam(?bool $value, string $permissionPrefix): void { - if (!$this->editable) { + if ($value === null) { return; } $user = Craft::$app->getUser()->getIdentity(); if (!$user) { - throw new QueryAbortedException('Could not execute query for product when no user found'); + throw new QueryAbortedException(); } - // Limit the query to only the sections the user has permission to edit - $this->subQuery->andWhere([ - 'commerce_products.typeId' => Plugin::getInstance()->getProductTypes()->getEditableProductTypeIds(), - ]); + $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + return; + } + + $authorizedTypeIds = []; + + foreach ($productTypes as $productType) { + if ($user->can("$permissionPrefix:$productType->uid")) { + $authorizedTypeIds[] = $productType->id; + } + } + + if (count($authorizedTypeIds) === count($productTypes)) { + // They have access to everything + if (!$value) { + throw new QueryAbortedException(); + } + return; + } + + if (empty($authorizedTypeIds)) { + // They don't have access to anything + if ($value) { + throw new QueryAbortedException(); + } + return; + } + + $condition = ['commerce_products.typeId' => $authorizedTypeIds]; + + if (!$value) { + $condition = ['not', $condition]; + } + + $this->subQuery->andWhere($condition); } /** @@ -917,7 +976,10 @@ private function _applyHasVariantParam(): void $variantQuery = $this->hasVariant; } elseif (is_array($this->hasVariant)) { $query = Variant::find(); - $variantQuery = Craft::configure($query, $this->hasVariant); + + $criteria = ProductQueryHelper::cleanseQueryCriteria($this->hasVariant); + + $variantQuery = Craft::configure($query, $criteria); } else { throw new QueryAbortedException('Invalid param used. ProductQuery::hasVariant param only expects a variant query or variant query config.'); } diff --git a/src/elements/db/VariantQuery.php b/src/elements/db/VariantQuery.php index ff90cb9fb3..9ecd9fa699 100755 --- a/src/elements/db/VariantQuery.php +++ b/src/elements/db/VariantQuery.php @@ -66,9 +66,17 @@ class VariantQuery extends PurchasableQuery protected array $defaultOrderBy = ['elements_owners.sortOrder' => SORT_ASC]; /** - * @var bool Whether to only return variants that the user has permission to edit. + * @var bool|null Whether to only return variants that the user has permission to view. + * @used-by editable() */ - public bool $editable = false; + public ?bool $editable = null; + + /** + * @var bool|null Whether to only return variants that the user has permission to save. + * @used-by savable() + * @since 5.6.0 + */ + public ?bool $savable = null; /** * @var bool|null @@ -432,6 +440,33 @@ public function maxQty(mixed $value): VariantQuery return $this; } + /** + * Sets the [[$editable]] property. + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference + * @uses $editable + */ + public function editable(?bool $value = true): static + { + $this->editable = $value; + return $this; + } + + /** + * Sets the [[$savable]] property. + * + * @param bool|null $value The property value (defaults to true) + * @return static self reference + * @uses $savable + * @since 5.6.0 + */ + public function savable(?bool $value = true): static + { + $this->savable = $value; + return $this; + } + /** * @param Connection|null $db * @return VariantCollection @@ -741,6 +776,8 @@ protected function beforePrepare(): bool } $this->_applyHasProductParam(); + $this->_applyEditableParam($this->editable, 'commerce-editProductType'); + $this->_applyEditableParam($this->savable, 'commerce-editProductType'); return parent::beforePrepare(); } @@ -794,7 +831,10 @@ private function _applyHasProductParam(): void $productQuery = $this->hasProduct; } elseif (is_array($this->hasProduct)) { $productQuery = Product::find(); - $productQuery = Craft::configure($productQuery, $this->hasProduct); + + $criteria = ProductQueryHelper::cleanseQueryCriteria($this->hasProduct); + + $productQuery = Craft::configure($productQuery, $criteria); } else { return; } @@ -808,6 +848,64 @@ private function _applyHasProductParam(): void $this->subQuery->andWhere(['commerce_variants.primaryOwnerId' => $productQuery]); } + /** + * Applies an authorization param to the query being prepared. + * + * @param bool|null $value + * @param string $permissionPrefix + * @throws QueryAbortedException + */ + private function _applyEditableParam(?bool $value, string $permissionPrefix): void + { + if ($value === null) { + return; + } + + $user = Craft::$app->getUser()->getIdentity(); + + if (!$user) { + throw new QueryAbortedException(); + } + + $productTypes = Plugin::getInstance()->getProductTypes()->getAllProductTypes(); + + if (empty($productTypes)) { + return; + } + + $authorizedTypeIds = []; + + foreach ($productTypes as $productType) { + if ($user->can("$permissionPrefix:$productType->uid")) { + $authorizedTypeIds[] = $productType->id; + } + } + + if (count($authorizedTypeIds) === count($productTypes)) { + // They have access to everything + if (!$value) { + throw new QueryAbortedException(); + } + return; + } + + if (empty($authorizedTypeIds)) { + // They don't have access to anything + if ($value) { + throw new QueryAbortedException(); + } + return; + } + + $condition = ['commerce_products.typeId' => $authorizedTypeIds]; + + if (!$value) { + $condition = ['not', $condition]; + } + + $this->subQuery->andWhere($condition); + } + /** * Applies the 'productStatus' param to the query being prepared. * diff --git a/src/gql/handlers/RelatedProducts.php b/src/gql/handlers/RelatedProducts.php new file mode 100644 index 0000000000..49714dc334 --- /dev/null +++ b/src/gql/handlers/RelatedProducts.php @@ -0,0 +1,31 @@ + + * @since 5.6.0 + */ +class RelatedProducts extends RelationArgumentHandler +{ + protected string $argumentName = 'relatedToProducts'; + + /** + * @inheritdoc + */ + protected function handleArgument($argumentValue): mixed + { + $argumentValue = parent::handleArgument($argumentValue); + return $this->getIds(Product::class, $argumentValue); + } +} diff --git a/src/gql/handlers/RelatedVariants.php b/src/gql/handlers/RelatedVariants.php new file mode 100644 index 0000000000..d30a4c99de --- /dev/null +++ b/src/gql/handlers/RelatedVariants.php @@ -0,0 +1,31 @@ + + * @since 5.6.0 + */ +class RelatedVariants extends RelationArgumentHandler +{ + protected string $argumentName = 'relatedToVariants'; + + /** + * @inheritdoc + */ + protected function handleArgument($argumentValue): mixed + { + $argumentValue = parent::handleArgument($argumentValue); + return $this->getIds(Variant::class, $argumentValue); + } +} diff --git a/src/gql/types/input/criteria/ProductRelation.php b/src/gql/types/input/criteria/ProductRelation.php new file mode 100644 index 0000000000..0bb5243aba --- /dev/null +++ b/src/gql/types/input/criteria/ProductRelation.php @@ -0,0 +1,39 @@ + + * @since 5.6.0 + */ +class ProductRelation extends InputObjectType +{ + /** + * @return mixed + */ + public static function getType(): mixed + { + $typeName = 'ProductRelationCriteriaInput'; + + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ + 'name' => $typeName, + 'fields' => fn() => [ + ...ProductArguments::getArguments(), + ...ProductArguments::getContentArguments(), + ...RelationCriteria::getArguments(), + ], + ])); + } +} diff --git a/src/gql/types/input/criteria/VariantRelation.php b/src/gql/types/input/criteria/VariantRelation.php new file mode 100644 index 0000000000..24e6344d30 --- /dev/null +++ b/src/gql/types/input/criteria/VariantRelation.php @@ -0,0 +1,39 @@ + + * @since 5.6.0 + */ +class VariantRelation extends InputObjectType +{ + /** + * @return mixed + */ + public static function getType(): mixed + { + $typeName = 'VariantRelationCriteriaInput'; + + return GqlEntityRegistry::getOrCreate($typeName, fn() => new InputObjectType([ + 'name' => $typeName, + 'fields' => fn() => [ + ...VariantArguments::getArguments(), + ...VariantArguments::getContentArguments(), + ...RelationCriteria::getArguments(), + ], + ])); + } +} diff --git a/src/helpers/ProductQuery.php b/src/helpers/ProductQuery.php index 3bb80ab870..d557e28e95 100644 --- a/src/helpers/ProductQuery.php +++ b/src/helpers/ProductQuery.php @@ -7,9 +7,13 @@ namespace craft\commerce\helpers; +use Craft; use craft\base\Element; use craft\commerce\elements\Product; +use craft\controllers\ElementIndexesController; +use craft\controllers\ElementSearchController; use craft\helpers\Db; +use craft\helpers\ElementHelper; use DateTime; /** @@ -80,4 +84,20 @@ public static function statusCondition(string $status, string $tablePrefix = '') default => false, }; } + + /** + * @param array $criteria + * @return array + * @since 5.6.0 + */ + public static function cleanseQueryCriteria(array $criteria): array + { + // Figure out if creating the query has come from a request where params are passed to a controller action + $controller = Craft::$app->controller; + if ($controller instanceof ElementIndexesController || $controller instanceof ElementSearchController) { + $criteria = ElementHelper::cleanseQueryCriteria($criteria); + } + + return $criteria; + } } diff --git a/src/migrations/Install.php b/src/migrations/Install.php index 784645d8e2..267d7cf231 100644 --- a/src/migrations/Install.php +++ b/src/migrations/Install.php @@ -640,12 +640,14 @@ public function createTables(): void 'variantTitleFormat' => $this->string()->notNull(), 'variantTitleTranslationMethod' => $this->string()->defaultValue('site')->notNull(), 'variantTitleTranslationKeyFormat' => $this->string(), + 'variantUiLabelFormat' => $this->string()->notNull()->defaultValue('{title}'), // Product title stuff 'hasProductTitleField' => $this->boolean()->notNull()->defaultValue(true), 'productTitleFormat' => $this->string(), 'productTitleTranslationMethod' => $this->string()->defaultValue('site')->notNull(), 'productTitleTranslationKeyFormat' => $this->string(), + 'productUiLabelFormat' => $this->string()->notNull()->defaultValue('{title}'), // Slug stuff 'showSlugField' => $this->boolean()->notNull()->defaultValue(true), diff --git a/src/migrations/m260206_000000_add_ui_label_formats.php b/src/migrations/m260206_000000_add_ui_label_formats.php new file mode 100644 index 0000000000..10cddc2415 --- /dev/null +++ b/src/migrations/m260206_000000_add_ui_label_formats.php @@ -0,0 +1,52 @@ +db->columnExists(Table::PRODUCTTYPES, 'variantUiLabelFormat')) { + $this->addColumn( + Table::PRODUCTTYPES, + 'variantUiLabelFormat', + $this->string()->notNull()->defaultValue('{title}')->after('variantTitleTranslationKeyFormat') + ); + } + + if (!$this->db->columnExists(Table::PRODUCTTYPES, 'productUiLabelFormat')) { + $this->addColumn( + Table::PRODUCTTYPES, + 'productUiLabelFormat', + $this->string()->notNull()->defaultValue('{title}')->after('productTitleTranslationKeyFormat') + ); + } + + return true; + } + + /** + * @inheritdoc + */ + public function safeDown(): bool + { + $this->dropColumn(Table::PRODUCTTYPES, 'variantUiLabelFormat'); + $this->dropColumn(Table::PRODUCTTYPES, 'productUiLabelFormat'); + + return true; + } +} diff --git a/src/models/ProductType.php b/src/models/ProductType.php index a38649db9e..2f71970083 100644 --- a/src/models/ProductType.php +++ b/src/models/ProductType.php @@ -96,6 +96,12 @@ class ProductType extends Model implements FieldLayoutProviderInterface */ public string $variantTitleFormat = '{product.title}'; + /** + * @var string Variant UI label format + * @since 5.6.0 + */ + public string $variantUiLabelFormat = '{title}'; + /** * @var string Variant title translation method * @phpstan-var Field::TRANSLATION_METHOD_NONE|Field::TRANSLATION_METHOD_SITE|Field::TRANSLATION_METHOD_SITE_GROUP|Field::TRANSLATION_METHOD_LANGUAGE|Field::TRANSLATION_METHOD_CUSTOM @@ -119,6 +125,12 @@ class ProductType extends Model implements FieldLayoutProviderInterface */ public string $productTitleFormat = ''; + /** + * @var string Product UI label format + * @since 5.6.0 + */ + public string $productUiLabelFormat = '{title}'; + /** * @var string Product title translation method * @phpstan-var Field::TRANSLATION_METHOD_NONE|Field::TRANSLATION_METHOD_SITE|Field::TRANSLATION_METHOD_SITE_GROUP|Field::TRANSLATION_METHOD_LANGUAGE|Field::TRANSLATION_METHOD_CUSTOM @@ -665,12 +677,14 @@ public function getConfig(): array 'variantTitleFormat' => $this->variantTitleFormat, 'variantTitleTranslationMethod' => $this->variantTitleTranslationMethod, 'variantTitleTranslationKeyFormat' => $this->variantTitleTranslationKeyFormat, + 'variantUiLabelFormat' => $this->variantUiLabelFormat, // Product title field 'hasProductTitleField' => $this->hasProductTitleField, 'productTitleFormat' => $this->productTitleFormat, 'productTitleTranslationMethod' => $this->productTitleTranslationMethod, 'productTitleTranslationKeyFormat' => $this->productTitleTranslationKeyFormat, + 'productUiLabelFormat' => $this->productUiLabelFormat, // Slug field 'showSlugField' => $this->showSlugField, diff --git a/src/records/ProductType.php b/src/records/ProductType.php index 6f471a5711..bbaa63efb3 100644 --- a/src/records/ProductType.php +++ b/src/records/ProductType.php @@ -33,10 +33,12 @@ * @property string $variantTitleFormat * @property string $variantTitleTranslationMethod * @property string $variantTitleTranslationKeyFormat + * @property string $variantUiLabelFormat * @property bool $hasProductTitleField * @property string $productTitleFormat * @property string $productTitleTranslationMethod * @property string $productTitleTranslationKeyFormat + * @property string $productUiLabelFormat * @property bool $showSlugField * @property string $slugTranslationMethod * @property string $slugTranslationKeyFormat diff --git a/src/services/Carts.php b/src/services/Carts.php index cde1ccd525..76a3a576be 100644 --- a/src/services/Carts.php +++ b/src/services/Carts.php @@ -279,7 +279,7 @@ public function forgetCart(): void */ public function generateCartNumber(): string { - return md5(uniqid((string)mt_rand(), true)); + return bin2hex(random_bytes(16)); } /** diff --git a/src/services/ProductTypes.php b/src/services/ProductTypes.php index 1512f26967..1b27b0c64d 100755 --- a/src/services/ProductTypes.php +++ b/src/services/ProductTypes.php @@ -408,6 +408,7 @@ public function handleChangedProductType(ConfigEvent $event): void } $productTypeRecord->variantTitleFormat = $variantTitleFormat; $productTypeRecord->hasVariantTitleField = $hasVariantTitleField; + $productTypeRecord->variantUiLabelFormat = $data['variantUiLabelFormat'] ?? '{title}'; // Product title fields $hasProductTitleField = $data['hasProductTitleField']; @@ -418,6 +419,7 @@ public function handleChangedProductType(ConfigEvent $event): void } $productTypeRecord->productTitleFormat = $productTitleFormat; $productTypeRecord->hasProductTitleField = $hasProductTitleField; + $productTypeRecord->productUiLabelFormat = $data['productUiLabelFormat'] ?? '{title}'; // Slug fields $productTypeRecord->showSlugField = $data['showSlugField'] ?? true; @@ -997,6 +999,16 @@ private function _createProductTypeQuery(): Query $query->addSelect('productTypes.previewTargets'); } + /** @since 5.6 */ + if ($db->columnExists(Table::PRODUCTTYPES, 'variantUiLabelFormat')) { + $query->addSelect('productTypes.variantUiLabelFormat'); + } + + /** @since 5.6 */ + if ($db->columnExists(Table::PRODUCTTYPES, 'productUiLabelFormat')) { + $query->addSelect('productTypes.productUiLabelFormat'); + } + return $query; } diff --git a/src/services/ShippingRuleCategories.php b/src/services/ShippingRuleCategories.php index ac59f6bac7..26a1510232 100644 --- a/src/services/ShippingRuleCategories.php +++ b/src/services/ShippingRuleCategories.php @@ -46,6 +46,34 @@ public function getShippingRuleCategoriesByRuleId(int $ruleId): array return $rules; } + /** + * Returns an array of shipping rule categories indexed by rule ID. + * + * @param int[] $ruleIds + * @return array + * @since 5.6.0 + */ + public function getShippingRuleCategoriesByRuleIds(array $ruleIds): array + { + if (empty($ruleIds)) { + return []; + } + + $categoriesByRuleId = []; + + $rows = $this->_createShippingRuleCategoriesQuery() + ->where(['shippingRuleId' => $ruleIds]) + ->all(); + + foreach ($rows as $row) { + $ruleId = $row['shippingRuleId']; + $categoryId = $row['shippingCategoryId']; + $categoriesByRuleId[$ruleId][$categoryId] = new ShippingRuleCategory($row); + } + + return $categoriesByRuleId; + } + /** * Save a shipping rule category. * diff --git a/src/services/ShippingRules.php b/src/services/ShippingRules.php index 81484b09a3..ced0541cd3 100644 --- a/src/services/ShippingRules.php +++ b/src/services/ShippingRules.php @@ -62,6 +62,9 @@ public function getAllShippingRules(): Collection $this->_allShippingRules = collect($allShippingRules); + // Eager load shipping rule categories + $this->_eagerLoadShippingRuleCategories($this->_allShippingRules); + return $this->_allShippingRules; } @@ -239,4 +242,28 @@ private function _createShippingRulesQuery(): Query return $query; } + + /** + * Eager loads shipping rule categories for a collection of shipping rules. + * + * @param Collection $shippingRules + */ + private function _eagerLoadShippingRuleCategories(Collection $shippingRules): void + { + $ruleIds = $shippingRules->pluck('id')->filter()->all(); + + if (empty($ruleIds)) { + return; + } + + $categoriesByRuleId = Plugin::getInstance() + ->getShippingRuleCategories() + ->getShippingRuleCategoriesByRuleIds($ruleIds); + + foreach ($shippingRules as $rule) { + if ($rule->id !== null) { + $rule->setShippingRuleCategories($categoriesByRuleId[$rule->id] ?? []); + } + } + } } diff --git a/src/templates/inventory/levels/_updateInventoryLevelModal.twig b/src/templates/inventory/levels/_updateInventoryLevelModal.twig index 03efd2dddf..f42f965f06 100644 --- a/src/templates/inventory/levels/_updateInventoryLevelModal.twig +++ b/src/templates/inventory/levels/_updateInventoryLevelModal.twig @@ -10,8 +10,8 @@ name: 'updateAction', value: updateAction, options: [ - { label: "Set"|t('commerce'), value: "set" }, - { label: "Adjust"|t('commerce'), value: "adjust" } + { label: "Set to"|t('commerce'), value: "set" }, + { label: "Adjust by"|t('commerce'), value: "adjust" } ] }) }} diff --git a/src/templates/settings/producttypes/_edit.twig b/src/templates/settings/producttypes/_edit.twig index 1aeb8dc038..c91f433ba5 100644 --- a/src/templates/settings/producttypes/_edit.twig +++ b/src/templates/settings/producttypes/_edit.twig @@ -106,6 +106,17 @@ disabled: disabled, }) }} + + {{ forms.textField({ + label: 'UI Label Format'|t('app'), + instructions: 'How products should be labeled within the control panel.'|t('commerce'), + id: 'productUiLabelFormat', + name: 'productUiLabelFormat', + class: 'code ltr', + value: productType.productUiLabelFormat, + errors: productType.getErrors('productUiLabelFormat'), + disabled: disabled, + }) }} {% endmacro %} {% macro variantTitleFormatField(productType, disabled) %} @@ -179,6 +190,17 @@ }) }} + {{ forms.textField({ + label: 'Variant UI Label Format'|t('commerce'), + instructions: 'How variants should be labeled within the control panel.'|t('commerce'), + id: 'variantUiLabelFormat', + name: 'variantUiLabelFormat', + class: 'code ltr', + value: productType.variantUiLabelFormat, + errors: productType.getErrors('variantUiLabelFormat'), + disabled: disabled, + }) }} + {% endmacro %} @@ -443,6 +465,7 @@ {% if not headlessMode %} {{ forms.editableTableField({ label: 'Preview Targets'|t('app'), + instructions: 'Locations that should be available for previewing products in this product type.'|t('commerce'), id: 'previewTargets', name: 'previewTargets', cols: { diff --git a/src/translations/en/commerce.php b/src/translations/en/commerce.php index 5b9a986f32..01b210e147 100644 --- a/src/translations/en/commerce.php +++ b/src/translations/en/commerce.php @@ -43,8 +43,8 @@ 'Address copied to user.' => 'Address copied to user.', 'Address not found.' => 'Address not found.', 'Adjust Quantity' => 'Adjust Quantity', + 'Adjust by' => 'Adjust by', 'Adjust price when included rate is disqualified?' => 'Adjust price when included rate is disqualified?', - 'Adjust' => 'Adjust', 'Adjustments' => 'Adjustments', 'Administrative Area Code of Origin' => 'Administrative Area Code of Origin', 'Advanced' => 'Advanced', @@ -54,6 +54,7 @@ 'All active subscriptions' => 'All active subscriptions', 'All customers' => 'All customers', 'All products' => 'All products', + 'All variants must have a SKU.' => 'All variants must have a SKU.', 'All' => 'All', 'Allow Checkout Without Payment' => 'Allow Checkout Without Payment', 'Allow Empty Cart On Checkout' => 'Allow Empty Cart On Checkout', @@ -527,9 +528,11 @@ 'How many times one email address is allowed to use this discount. This applies to all previous orders, whether guest or user. Set to zero for unlimited use by guests or users.' => 'How many times one email address is allowed to use this discount. This applies to all previous orders, whether guest or user. Set to zero for unlimited use by guests or users.', 'How many times one user is allowed to use this discount. If this is set to something besides zero, the discount will only be available to signed in users.' => 'How many times one user is allowed to use this discount. If this is set to something besides zero, the discount will only be available to signed in users.', 'How many times this discount can be used in total by guests or signed in users. Set zero for unlimited use.' => 'How many times this discount can be used in total by guests or signed in users. Set zero for unlimited use.', + 'How products should be labeled within the control panel.' => 'How products should be labeled within the control panel.', 'How the Purchasables and Categories are related, which determines the matching items. See [Relations Terminology]({link}).' => 'How the Purchasables and Categories are related, which determines the matching items. See [Relations Terminology]({link}).', 'How this product will be described on a line item in an order. You can include tags that output properties, such as {ex1} or {ex2}' => 'How this product will be described on a line item in an order. You can include tags that output properties, such as {ex1} or {ex2}', 'How this shipping method will be referred to in templates and forms.' => 'How this shipping method will be referred to in templates and forms.', + 'How variants should be labeled within the control panel.' => 'How variants should be labeled within the control panel.', 'How you’ll refer to this PDF in the templates.' => 'How you’ll refer to this PDF in the templates.', 'How you’ll refer to this product type in the templates.' => 'How you’ll refer to this product type in the templates.', 'How you’ll refer to this shipping category in the templates.' => 'How you’ll refer to this shipping category in the templates.', @@ -628,6 +631,7 @@ 'Link' => 'Link', 'Live' => 'Live', 'Location' => 'Location', + 'Locations that should be available for previewing products in this product type.' => 'Locations that should be available for previewing products in this product type.', 'MM' => 'MM', 'Make a payment' => 'Make a payment', 'Make this the primary store' => 'Make this the primary store', @@ -996,7 +1000,7 @@ 'Set the price to a percentage of the original price' => 'Set the price to a percentage of the original price', 'Set the sale price to a flat amount' => 'Set the sale price to a flat amount', 'Set the sale price to a percentage of the original price' => 'Set the sale price to a percentage of the original price', - 'Set' => 'Set', + 'Set to' => 'Set to', 'Settings saved.' => 'Settings saved.', 'Settings' => 'Settings', 'Share cart…' => 'Share cart…', @@ -1291,6 +1295,7 @@ 'Variant Stock' => 'Variant Stock', 'Variant Title Format' => 'Variant Title Format', 'Variant Tracks Stock' => 'Variant Tracks Stock', + 'Variant UI Label Format' => 'Variant UI Label Format', 'Variant has no product.' => 'Variant has no product.', 'Variants not restored.' => 'Variants not restored.', 'Variants restored.' => 'Variants restored.', diff --git a/tests/unit/services/ShippingRulesTest.php b/tests/unit/services/ShippingRulesTest.php new file mode 100644 index 0000000000..197f3ba5ac --- /dev/null +++ b/tests/unit/services/ShippingRulesTest.php @@ -0,0 +1,135 @@ + + */ +class ShippingRulesTest extends Unit +{ + /** + * @var UnitTester + */ + protected $tester; + + /** + * @var ShippingRules + */ + protected ShippingRules $shippingRules; + + /** + * @var ShippingRuleCategories + */ + protected ShippingRuleCategories $shippingRuleCategories; + + /** + * @return array + */ + public function _fixtures(): array + { + return [ + 'shipping' => [ + 'class' => ShippingFixture::class, + ], + ]; + } + + public function _before(): void + { + parent::_before(); + + $this->shippingRules = Plugin::getInstance()->getShippingRules(); + $this->shippingRuleCategories = Plugin::getInstance()->getShippingRuleCategories(); + } + + public function testGetShippingRuleCategoriesByRuleIds(): void + { + $allRules = $this->shippingRules->getAllShippingRules(); + $ruleIds = $allRules->pluck('id')->filter()->all(); + + // Skip if no rules exist + if (empty($ruleIds)) { + $this->markTestSkipped('No shipping rules exist to test'); + } + + $categoriesByRuleId = $this->shippingRuleCategories->getShippingRuleCategoriesByRuleIds($ruleIds); + + // Result should be an array indexed by rule ID + $this->assertIsArray($categoriesByRuleId); + + // Each rule that has categories should have them indexed by category ID + foreach ($categoriesByRuleId as $ruleId => $categories) { + $this->assertContains($ruleId, $ruleIds, 'Rule ID in result should be one of the requested IDs'); + $this->assertIsArray($categories); + } + } + + public function testGetShippingRuleCategoriesByRuleIdsWithEmptyArray(): void + { + $result = $this->shippingRuleCategories->getShippingRuleCategoriesByRuleIds([]); + + $this->assertIsArray($result); + $this->assertEmpty($result); + } + + public function testEagerLoadingPopulatesShippingRuleCategories(): void + { + // Get all shipping rules - this should eager load categories + $allRules = $this->shippingRules->getAllShippingRules(); + + // Skip if no rules exist + if ($allRules->isEmpty()) { + $this->markTestSkipped('No shipping rules exist to test'); + } + + // Each rule should have its categories already loaded (not null) + // We verify this by checking that getShippingRuleCategories returns + // without triggering additional queries + foreach ($allRules as $rule) { + $categories = $rule->getShippingRuleCategories(); + $this->assertIsArray($categories); + } + } + + public function testBulkFetchMatchesSingleFetch(): void + { + $allRules = $this->shippingRules->getAllShippingRules(); + + // Skip if no rules exist + if ($allRules->isEmpty()) { + $this->markTestSkipped('No shipping rules exist to test'); + } + + $ruleIds = $allRules->pluck('id')->filter()->all(); + + // Fetch all categories in bulk + $bulkCategories = $this->shippingRuleCategories->getShippingRuleCategoriesByRuleIds($ruleIds); + + // Fetch categories one by one and compare + foreach ($ruleIds as $ruleId) { + $singleCategories = $this->shippingRuleCategories->getShippingRuleCategoriesByRuleId($ruleId); + $bulkForRule = $bulkCategories[$ruleId] ?? []; + + // Both should have the same category IDs + $this->assertEquals( + array_keys($singleCategories), + array_keys($bulkForRule), + "Categories for rule $ruleId should match between bulk and single fetch" + ); + } + } +}