Skip to content

Commit 70f6688

Browse files
authored
Merge pull request #4278 from craftcms/nathaniel/com-550-5x-disabled-variant-makes-it-impossible-to-change-sorting-of
[5.x] Disabled variants causing issues saving sort order when reordering
2 parents 6f452b2 + bb4dfa7 commit 70f6688

3 files changed

Lines changed: 95 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
## Unreleased
44

55
- Fixed a PHP error that occurred when marking an inventory transfer as pending. ([#4267](https://github.com/craftcms/commerce/issues/4267))
6+
- Fixed a bug where variants' sort order wasn’t being respected when reordering with disabled variants. ([#4270](https://github.com/craftcms/commerce/issues/4270))
67

78
## 5.6.1.1 - 2026-03-27
89

src/elements/Product.php

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@
2727
use craft\commerce\Plugin;
2828
use craft\commerce\records\Product as ProductRecord;
2929
use craft\controllers\ElementIndexesController;
30+
use craft\controllers\NestedElementsController;
3031
use craft\db\Query;
3132
use craft\elements\actions\CopyReferenceTag;
3233
use craft\elements\actions\Delete;
@@ -1166,13 +1167,13 @@ public function getCheapestVariant(bool $includeDisabled = false): ?Variant
11661167
}
11671168

11681169
/**
1169-
* Returns an array of the product's variants.
1170+
* Returns a collection of the product's variants.
11701171
*
1171-
* @param bool $includeDisabled
1172+
* @param bool|null $includeDisabled
11721173
* @return VariantCollection
11731174
* @throws InvalidConfigException
11741175
*/
1175-
public function getVariants(bool $includeDisabled = false): VariantCollection
1176+
public function getVariants(?bool $includeDisabled = null): VariantCollection
11761177
{
11771178
if ($this->_variants === null) {
11781179
if (!$this->id) {
@@ -1212,6 +1213,10 @@ public function getVariants(bool $includeDisabled = false): VariantCollection
12121213
});
12131214
}
12141215

1216+
// When reordering variants we need to make sure disabled variants are included when calculating sort order
1217+
// @TODO: Remove in 6.0 when updating `getVariants()` to start returning an element query instance.
1218+
$includeDisabled ??= Craft::$app->controller instanceof NestedElementsController;
1219+
12151220
return $this->_variants->filter(fn(Variant $variant) => $includeDisabled || ($variant->getStatus() === self::STATUS_ENABLED));
12161221
}
12171222

tests/unit/elements/product/ProductGetVariantsTest.php

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,12 @@
88
namespace craftcommercetests\unit\elements\product;
99

1010
use Codeception\Test\Unit;
11+
use Craft;
1112
use craft\commerce\elements\db\VariantQuery;
1213
use craft\commerce\elements\Product;
1314
use craft\commerce\elements\Variant;
1415
use craft\commerce\elements\VariantCollection;
16+
use craft\controllers\NestedElementsController;
1517
use craftcommercetests\fixtures\ProductFixture;
1618
use ReflectionClass;
1719

@@ -188,4 +190,88 @@ public function testGetVariantsIncludeDisabledParameter(): void
188190
}
189191
self::assertTrue($hasDisabledVariant);
190192
}
193+
194+
/**
195+
* Tests every combination of the nullable $includeDisabled parameter against the
196+
* NestedElementsController detection introduced alongside the signature change.
197+
* Also asserts that the internal $_variants collection is never mutated by the filter.
198+
*
199+
* @dataProvider getVariantsNullableIncludeDisabledDataProvider
200+
*/
201+
public function testGetVariantsNullableIncludeDisabled(?bool $includeDisabled, bool $useNestedElementsController, int $expectedCount): void
202+
{
203+
$originalController = Craft::$app->controller;
204+
205+
try {
206+
if ($useNestedElementsController) {
207+
$mockController = $this->getMockBuilder(NestedElementsController::class)
208+
->disableOriginalConstructor()
209+
->getMock();
210+
Craft::$app->controller = $mockController;
211+
}
212+
213+
$product = new Product();
214+
$product->typeId = 2000;
215+
216+
$enabled = new Variant();
217+
$enabled->enabled = true;
218+
$enabled->sku = 'enabled-sku';
219+
220+
$disabled = new Variant();
221+
$disabled->enabled = false;
222+
$disabled->sku = 'disabled-sku';
223+
224+
$product->setVariants([$enabled, $disabled]);
225+
226+
$result = $product->getVariants($includeDisabled);
227+
self::assertCount($expectedCount, $result);
228+
229+
// The internal collection must never be mutated by the filter —
230+
// regardless of which parameter was passed, all set variants must be retained.
231+
$reflection = new ReflectionClass($product);
232+
$variantsProperty = $reflection->getProperty('_variants');
233+
$variantsProperty->setAccessible(true);
234+
235+
/** @var VariantCollection $internalVariants */
236+
$internalVariants = $variantsProperty->getValue($product);
237+
self::assertInstanceOf(VariantCollection::class, $internalVariants);
238+
self::assertCount(2, $internalVariants, '_variants must retain all variants regardless of the filter applied');
239+
} finally {
240+
// Clean up so the controller state does not leak into subsequent tests
241+
Craft::$app->controller = $originalController;
242+
}
243+
}
244+
245+
/**
246+
* @return array<string, array{includeDisabled: bool|null, useNestedElementsController: bool, expectedCount: int}>
247+
*/
248+
public function getVariantsNullableIncludeDisabledDataProvider(): array
249+
{
250+
return [
251+
// null resolves to false when no NestedElementsController is active
252+
'null-no-controller-excludes-disabled' => [
253+
'includeDisabled' => null,
254+
'useNestedElementsController' => false,
255+
'expectedCount' => 1,
256+
],
257+
// null resolves to true when NestedElementsController is the active controller
258+
'null-nested-elements-controller-includes-disabled' => [
259+
'includeDisabled' => null,
260+
'useNestedElementsController' => true,
261+
'expectedCount' => 2,
262+
],
263+
// Explicit false must override the NestedElementsController detection
264+
'explicit-false-with-nested-elements-controller' => [
265+
'includeDisabled' => false,
266+
'useNestedElementsController' => true,
267+
'expectedCount' => 1,
268+
],
269+
// Explicit true must work even without a special controller
270+
'explicit-true-without-controller' => [
271+
'includeDisabled' => true,
272+
'useNestedElementsController' => false,
273+
'expectedCount' => 2,
274+
],
275+
];
276+
}
191277
}

0 commit comments

Comments
 (0)