diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md new file mode 100644 index 00000000000..a51212998a5 --- /dev/null +++ b/CHANGELOG-5.11.md @@ -0,0 +1,14 @@ +# Release Notes for Craft CMS 5.11 (WIP) + +### Development +- The `params` argument of the `url()` Twig function now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\web\DbSession`, which should be used instead of `yii\web\DbSession` to prevent “headers already sent” warnings from getting logged. ([#19139](https://github.com/craftcms/cms/issues/19139)) + +### Extensibility +- Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\services\Elements::reorderNestedElements()`. ([#19321](https://github.com/craftcms/cms/issues/19321)) +- The `$params` argument of `craft\helpers\UrlHelper::url()` now accepts `false` to remove all params from the passed-in URL. ([#19102](https://github.com/craftcms/cms/pull/19102)) + +### System +- Updated Twig to 3.28. diff --git a/composer.json b/composer.json index 1bef51ad8ac..8113c66be57 100644 --- a/composer.json +++ b/composer.json @@ -68,7 +68,7 @@ "symfony/var-dumper": "^5.0|^6.0|^7.0", "symfony/yaml": "^5.2.3|^6.0|^7.0", "theiconic/name-parser": "^1.2", - "twig/twig": "~3.27.0", + "twig/twig": "~3.28.0", "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.3.5", "yiisoft/yii2": "~2.0.55.0", diff --git a/composer.lock b/composer.lock index d16390af1fb..52697b00029 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": "d36b47860802c2f7f84fbfa81f5b2804", + "content-hash": "5e1315c0b13d8578cc2b675b2e0bb09c", "packages": [ { "name": "bacon/bacon-qr-code", @@ -4223,16 +4223,16 @@ }, { "name": "symfony/deprecation-contracts", - "version": "v3.7.1", + "version": "v3.7.0", "source": { "type": "git", "url": "https://github.com/symfony/deprecation-contracts.git", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d" + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d", - "reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/50f59d1f3ca46d41ac911f97a78626b6756af35b", + "reference": "50f59d1f3ca46d41ac911f97a78626b6756af35b", "shasum": "" }, "require": { @@ -4270,7 +4270,7 @@ "description": "A generic function and convention to trigger deprecation notices", "homepage": "https://symfony.com", "support": { - "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1" + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.0" }, "funding": [ { @@ -4290,7 +4290,7 @@ "type": "tidelift" } ], - "time": "2026-06-05T06:23:12+00:00" + "time": "2026-04-13T15:52:40+00:00" }, { "name": "symfony/dom-crawler", @@ -5462,16 +5462,16 @@ }, { "name": "symfony/polyfill-php83", - "version": "v1.38.2", + "version": "v1.38.1", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php83.git", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8" + "reference": "8339098cae28673c15cce00d80734af0453054e2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/796a26abb75ce49f3a84433cd81bf1009d73d5f8", - "reference": "796a26abb75ce49f3a84433cd81bf1009d73d5f8", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/8339098cae28673c15cce00d80734af0453054e2", + "reference": "8339098cae28673c15cce00d80734af0453054e2", "shasum": "" }, "require": { @@ -5518,7 +5518,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.2" + "source": "https://github.com/symfony/polyfill-php83/tree/v1.38.1" }, "funding": [ { @@ -5538,7 +5538,7 @@ "type": "tidelift" } ], - "time": "2026-05-27T06:51:48+00:00" + "time": "2026-05-26T12:51:13+00:00" }, { "name": "symfony/polyfill-php84", @@ -6776,16 +6776,16 @@ }, { "name": "twig/twig", - "version": "v3.27.1", + "version": "v3.28.0", "source": { "type": "git", "url": "https://github.com/twigphp/Twig.git", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74" + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/twigphp/Twig/zipball/ae2071bffb38f04847fc0864d730c94b9cb8ab74", - "reference": "ae2071bffb38f04847fc0864d730c94b9cb8ab74", + "url": "https://api.github.com/repos/twigphp/Twig/zipball/597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", + "reference": "597c12ed286fb9d1701a36684ce6e0cbe28ebc8b", "shasum": "" }, "require": { @@ -6840,7 +6840,7 @@ ], "support": { "issues": "https://github.com/twigphp/Twig/issues", - "source": "https://github.com/twigphp/Twig/tree/v3.27.1" + "source": "https://github.com/twigphp/Twig/tree/v3.28.0" }, "funding": [ { @@ -6852,7 +6852,7 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "voku/portable-ascii", diff --git a/src/controllers/NestedElementsController.php b/src/controllers/NestedElementsController.php index 2562b84047d..86e3cad6d05 100644 --- a/src/controllers/NestedElementsController.php +++ b/src/controllers/NestedElementsController.php @@ -9,7 +9,6 @@ use Craft; use craft\base\ElementInterface; -use craft\base\NestedElementInterface; use craft\db\Table; use craft\elements\db\ElementQueryInterface; use craft\elements\ElementCollection; @@ -81,38 +80,7 @@ public function actionReorder(): Response $ids = array_map(fn($id) => (int)$id, $this->request->getRequiredBodyParam('elementIds')); $offset = $this->request->getRequiredBodyParam('offset'); - if ($this->nestedElements instanceof ElementQueryInterface) { - $oldSortOrders = (clone $this->nestedElements) - ->status(null) - ->asArray() - ->select(['id', 'sortOrder']) - ->pairs(); - } else { - $oldSortOrders = $this->nestedElements - ->keyBy(fn(ElementInterface $element) => $element->id) - /** @phpstan-ignore-next-line */ - ->map(fn(NestedElementInterface $element) => $element->getSortOrder()) - ->all(); - } - - // Build the full list of IDs in the new sort order - $allIds = array_diff(array_keys($oldSortOrders), $ids); - array_splice($allIds, $offset, 0, $ids); - - // Update all the incorrect sort orders - foreach ($allIds as $i => $id) { - $sortOrder = $i + 1; - if (!isset($oldSortOrders[$id]) || $sortOrder !== $oldSortOrders[$id]) { - Db::update(Table::ELEMENTS_OWNERS, [ - 'sortOrder' => $sortOrder, - ], [ - 'ownerId' => $this->owner->id, - 'elementId' => $id, - ]); - } - } - - Craft::$app->getElements()->invalidateCachesForElement($this->owner); + Craft::$app->getElements()->reorderNestedElements($this->owner, $this->nestedElements, $ids, $offset); return $this->asSuccess(Craft::t('app', 'New {total, plural, =1{position} other{positions}} saved.', [ 'total' => count($ids), diff --git a/src/helpers/UrlHelper.php b/src/helpers/UrlHelper.php index fd4aefa9d08..e27013eb31e 100644 --- a/src/helpers/UrlHelper.php +++ b/src/helpers/UrlHelper.php @@ -155,12 +155,61 @@ public static function urlWithParams(string $url, array|string $params): string * @since 3.2.2 */ public static function removeParam(string $url, string $param): string + { + return static::removeParams($url, [$param]); + } + + /** + * Removes query string params from a URL. + * + * @param string $url + * @param string[] $params + * @return string + * @since 5.11.0 + */ + public static function removeParams(string $url, array $params): string + { + // Extract any params/fragment from the base URL + [$url, $urlParams, $fragment] = self::_extractParams($url); + + // Remove the params + foreach ($params as $param) { + unset($urlParams[$param]); + } + + // Rebuild + if (($query = static::buildQuery($urlParams)) !== '') { + $url .= '?' . $query; + } + if ($fragment !== null) { + $url .= '#' . $fragment; + } + return $url; + } + + /** + * Removes all query string params from a URL. + * + * @param string $url + * @param string[] $except Any params that should be left alone + * @return string + * @since 5.11.0 + */ + public static function removeAllParams(string $url, array $except = []): string { // Extract any params/fragment from the base URL [$url, $params, $fragment] = self::_extractParams($url); - // Remove the param - unset($params[$param]); + // Remove the params + if (!empty($except)) { + foreach (array_keys($params) as $param) { + if (!in_array($param, $except)) { + unset($params[$param]); + } + } + } else { + $params = []; + } // Rebuild if (($query = static::buildQuery($params)) !== '') { @@ -275,18 +324,20 @@ public static function rootRelativeUrl(string $url): string * Returns either a control panel or a site URL, depending on the request type. * * @param string $path - * @param array|string|null $params + * @param array|string|false|null $params The query params to add to the URL. If `false`, any existing params will be removed. * @param string|null $scheme * @param bool|null $showScriptName Whether the script name (index.php) should be included in the URL. * By default (null) it will defer to the `omitScriptNameInUrls` config setting. * @return string */ - public static function url(string $path = '', array|string|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): string + public static function url(string $path = '', array|string|false|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): string { // Return $path if it appears to be an absolute URL. if (static::isFullUrl($path)) { if ($params) { $path = static::urlWithParams($path, $params); + } elseif ($params === false) { + $path = static::removeAllParams($path); } if ($scheme !== null) { @@ -312,7 +363,7 @@ public static function url(string $path = '', array|string|null $params = null, $scheme = 'https'; } - return self::_createUrl($path, $params, $scheme, $cpUrl, showScriptName: $showScriptName); + return self::_createUrl($path, $params ?: null, $scheme, $cpUrl, showScriptName: $showScriptName); } /** @@ -596,12 +647,12 @@ public static function cpReferralUrl(): ?string } // Make sure the CP referred it - if (!str_starts_with($referrer, self::baseCpUrl())) { + if (!str_starts_with($referrer, static::baseCpUrl())) { return null; } // to ensure we're comparing uris strip base cp url and query string from the referrer first - $referrerFullUri = ltrim(StringHelper::removeLeft($referrer, self::baseCpUrl()), '/'); + $referrerFullUri = ltrim(StringHelper::removeLeft($referrer, static::baseCpUrl()), '/'); $referrerFullUri = substr($referrerFullUri, 0, strpos($referrerFullUri, '?') ?: null); // Make sure it didn't refer itself diff --git a/src/services/Elements.php b/src/services/Elements.php index 37db9b6d105..0d585a60943 100644 --- a/src/services/Elements.php +++ b/src/services/Elements.php @@ -918,6 +918,58 @@ public function invalidateCachesForElement(ElementInterface $element): void } } + /** + * Reorders nested elements for a given owner element. + * + * @param ElementInterface $owner The owner element + * @param ElementQueryInterface|ElementCollection $nestedElements The owner’s nested elements + * @param array $elementIds The nested element IDs that are being moved, in their new relative order + * @param int $offset The zero-based offset that `$elementIds` should be inserted at, relative to the owner’s + * other nested elements + * @since 5.11.0 + */ + public function reorderNestedElements( + ElementInterface $owner, + ElementQueryInterface|ElementCollection $nestedElements, + array $elementIds, + int $offset, + ): void { + $elementIds = array_map(fn($id) => (int)$id, $elementIds); + + if ($nestedElements instanceof ElementQueryInterface) { + $oldSortOrders = (clone $nestedElements) + ->status(null) + ->asArray() + ->select(['id', 'sortOrder']) + ->pairs(); + } else { + $oldSortOrders = $nestedElements + ->keyBy(fn(ElementInterface $element) => $element->id) + /** @phpstan-ignore-next-line */ + ->map(fn(NestedElementInterface $element) => $element->getSortOrder()) + ->all(); + } + + // Build the full list of IDs in the new sort order + $allIds = array_diff(array_keys($oldSortOrders), $elementIds); + array_splice($allIds, $offset, 0, $elementIds); + + // Update all the incorrect sort orders + foreach ($allIds as $i => $id) { + $sortOrder = $i + 1; + if (!isset($oldSortOrders[$id]) || $sortOrder !== $oldSortOrders[$id]) { + Db::update(Table::ELEMENTS_OWNERS, [ + 'sortOrder' => $sortOrder, + ], [ + 'ownerId' => $owner->id, + 'elementId' => $id, + ]); + } + } + + $this->invalidateCachesForElement($owner); + } + // Finding Elements // ------------------------------------------------------------------------- diff --git a/src/web/DbSession.php b/src/web/DbSession.php new file mode 100644 index 00000000000..6963edb30f1 --- /dev/null +++ b/src/web/DbSession.php @@ -0,0 +1,33 @@ + + * @since 5.11.0 + * @mixin SessionBehavior + */ +class DbSession extends \yii\web\DbSession +{ + /** + * @inheritdoc + */ + public function has($key): bool + { + // don't open the session if the headers were already sent + if (!$this->getIsActive() && headers_sent()) { + return isset($_SESSION[$key]); + } + + return parent::has($key); + } +} diff --git a/tests/unit/helpers/UrlHelperTest.php b/tests/unit/helpers/UrlHelperTest.php index 6eb9c23ce71..c30e86627a9 100644 --- a/tests/unit/helpers/UrlHelperTest.php +++ b/tests/unit/helpers/UrlHelperTest.php @@ -158,6 +158,39 @@ public function testStripQueryString(string $expected, string $url): void self::assertSame($expected, UrlHelper::stripQueryString($url)); } + /** + * @dataProvider removeParamDataProvider + * @param string $expected + * @param string $url + * @param string $param + */ + public function testRemoveParam(string $expected, string $url, string $param): void + { + self::assertSame($expected, UrlHelper::removeParam($url, $param)); + } + + /** + * @dataProvider removeParamsDataProvider + * @param string $expected + * @param string $url + * @param string[] $params + */ + public function testRemoveParams(string $expected, string $url, array $params): void + { + self::assertSame($expected, UrlHelper::removeParams($url, $params)); + } + + /** + * @dataProvider removeAllParamsDataProvider + * @param string $expected + * @param string $url + * @param string[] $except + */ + public function testRemoveAllParams(string $expected, string $url, array $except = []): void + { + self::assertSame($expected, UrlHelper::removeAllParams($url, $except)); + } + /** * @dataProvider encodeParamsDataProvider */ @@ -192,11 +225,11 @@ public function testRootRelativeUrl(string $expected, string $url): void * @dataProvider urlFunctionDataProvider * @param string $expected * @param string $path - * @param array|null $params + * @param array|string|false|null $params * @param string|null $scheme * @param bool|null $showScriptName */ - public function testUrlFunction(string $expected, string $path = '', ?array $params = null, ?string $scheme = null, ?bool $showScriptName = null): void + public function testUrlFunction(string $expected, string $path = '', array|string|false|null $params = null, ?string $scheme = null, ?bool $showScriptName = null): void { $scheme ??= 'https'; $expected = $this->_prepExpectedUrl($expected, $scheme); @@ -435,6 +468,129 @@ public static function stripQueryStringDataProvider(): array ]; } + /** + * Tests for UrlHelper::removeParam() method + * + * @return array + */ + public static function removeParamDataProvider(): array + { + return [ + 'basic' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + 'param1', + ], + 'last-param' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + 'param1', + ], + 'missing-param' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + 'param2', + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + 'param1', + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + 'param1', + ], + ]; + } + + /** + * Tests for UrlHelper::removeParams() method + * + * @return array + */ + public static function removeParamsDataProvider(): array + { + return [ + 'multiple' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param1', 'param3'], + ], + 'all-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + ['param1', 'param2'], + ], + 'missing-params' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + ['param2', 'param3'], + ], + 'empty-params' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + self::ABSOLUTE_URL_HTTPS . '?param1=value1', + [], + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + ['param1'], + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3#anchor', + ['param1', 'param3'], + ], + ]; + } + + /** + * Tests for UrlHelper::removeAllParams() method + * + * @return array + */ + public static function removeAllParamsDataProvider(): array + { + return [ + 'basic' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + [], + ], + 'except' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param2'], + ], + 'except-multiple' => [ + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m3=value3', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2¶m3=value3', + ['param1', 'param3'], + ], + 'except-missing' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2', + ['param3'], + ], + 'no-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS, + [], + ], + 'keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + [], + ], + 'except-keeps-fragment' => [ + self::ABSOLUTE_URL_HTTPS . '?param2=value2#anchor', + self::ABSOLUTE_URL_HTTPS . '?param1=value1¶m2=value2#anchor', + ['param2'], + ], + ]; + } + /** * Tests for UrlHelper::urlWithParams() method * @@ -716,6 +872,11 @@ public static function urlFunctionDataProvider(): array ['returnUrl' => 'https://example.test/admin/entries?site={handle}'], 'https', ], + 'remove-params' => [ + self::ABSOLUTE_URL_HTTPS, + self::ABSOLUTE_URL_HTTPS . '?x-craft-preview=foo&test=bar', + false, + ], ]; } diff --git a/tests/unit/services/ElementsTest.php b/tests/unit/services/ElementsTest.php index e567e77dd62..66112cb2a84 100644 --- a/tests/unit/services/ElementsTest.php +++ b/tests/unit/services/ElementsTest.php @@ -12,6 +12,16 @@ use Craft; use craft\elements\Entry; +use craft\enums\PropagationMethod; +use craft\fieldlayoutelements\CustomField; +use craft\fieldlayoutelements\entries\EntryTitleField; +use craft\fields\Matrix; +use craft\fields\PlainText; +use craft\models\EntryType; +use craft\models\FieldLayout; +use craft\models\FieldLayoutTab; +use craft\models\Section; +use craft\models\Section_SiteSettings; use craft\services\Elements; use craft\test\TestCase; use craft\test\TestSetup; @@ -21,6 +31,7 @@ use crafttests\fixtures\settings\GeneralConfigSettingFixture; use crafttests\fixtures\SitesFixture; use crafttests\fixtures\UserFixture; +use RuntimeException; /** * Unit tests for the config service @@ -36,6 +47,12 @@ class ElementsTest extends TestCase */ public $elements; + private PlainText $blockTextField; + private Matrix $matrixField; + private EntryType $blockEntryType; + private EntryType $ownerEntryType; + private Section $section; + /** * @return void */ @@ -123,5 +140,135 @@ protected function _before(): void { parent::_before(); $this->elements = Craft::$app->getElements(); + + $primarySiteId = Craft::$app->getSites()->getPrimarySite()->id; + + // A single-site, non-versioned section with a Matrix field, built directly (rather than via + // shared fixtures) so this test doesn't have to reason about revision-creation or multi-site + // propagation - it only cares about reordering already-saved nested entries. + $this->blockTextField = new PlainText(); + $this->blockTextField->name = 'Block Text'; + $this->blockTextField->handle = 'blockText'; + if (!Craft::$app->getFields()->saveField($this->blockTextField)) { + throw new RuntimeException('Could not save block text field.'); + } + + $this->blockEntryType = new EntryType(); + $this->blockEntryType->name = 'Reorder Test Block'; + $this->blockEntryType->handle = 'reorderTestBlock'; + $this->blockEntryType->hasTitleField = false; + $this->blockEntryType->titleFormat = '{id}'; + $this->blockEntryType->setFieldLayout($this->_makeFieldLayout(Entry::class, [$this->blockTextField])); + if (!Craft::$app->getEntries()->saveEntryType($this->blockEntryType)) { + throw new RuntimeException('Could not save block entry type.'); + } + + $this->matrixField = new Matrix(); + $this->matrixField->name = 'Reorder Test Matrix'; + $this->matrixField->handle = 'reorderTestMatrix'; + $this->matrixField->propagationMethod = PropagationMethod::None; + $this->matrixField->setEntryTypes([$this->blockEntryType]); + if (!Craft::$app->getFields()->saveField($this->matrixField)) { + throw new RuntimeException('Could not save matrix field.'); + } + + $this->ownerEntryType = new EntryType(); + $this->ownerEntryType->name = 'Reorder Test Owner'; + $this->ownerEntryType->handle = 'reorderTestOwner'; + $this->ownerEntryType->hasTitleField = true; + $this->ownerEntryType->setFieldLayout($this->_makeFieldLayout(Entry::class, [$this->matrixField], [new EntryTitleField()])); + if (!Craft::$app->getEntries()->saveEntryType($this->ownerEntryType)) { + throw new RuntimeException('Could not save owner entry type.'); + } + + $this->section = new Section(); + $this->section->name = 'Reorder Test Section'; + $this->section->handle = 'reorderTestSection'; + $this->section->type = Section::TYPE_CHANNEL; + $this->section->enableVersioning = false; + $this->section->propagationMethod = PropagationMethod::All; + $this->section->setEntryTypes([$this->ownerEntryType]); + $this->section->setSiteSettings([ + new Section_SiteSettings([ + 'siteId' => $primarySiteId, + 'enabledByDefault' => true, + 'hasUrls' => false, + ]), + ]); + if (!Craft::$app->getEntries()->saveSection($this->section)) { + throw new RuntimeException('Could not save section.'); + } + } + + /** + * @inheritdoc + */ + protected function _after(): void + { + Craft::$app->getEntries()->deleteSection($this->section); + Craft::$app->getEntries()->deleteEntryType($this->ownerEntryType); + Craft::$app->getEntries()->deleteEntryType($this->blockEntryType); + Craft::$app->getFields()->deleteField($this->matrixField); + Craft::$app->getFields()->deleteField($this->blockTextField); + parent::_after(); + } + + /** + * Builds a single-tab field layout containing the given layout elements. + * + * @param class-string $type + * @param \craft\base\FieldInterface[] $fields + * @param \craft\base\FieldLayoutElement[] $leadingElements + */ + private function _makeFieldLayout(string $type, array $fields, array $leadingElements = []): FieldLayout + { + $fieldLayout = new FieldLayout(['type' => $type]); + $tab = new FieldLayoutTab(['name' => 'Content']); + $tab->setLayout($fieldLayout); + $tab->setElements([ + ...$leadingElements, + ...array_map(fn($field) => new CustomField($field), $fields), + ]); + $fieldLayout->setTabs([$tab]); + return $fieldLayout; + } + + // Covers #19321: reorderNestedElements() exposes the reordering logic that previously only lived + // inline in NestedElementsController::actionReorder(), so it can be called without going through + // a CP request. + public function testReorderNestedElementsMovesElementToNewOffset(): void + { + $owner = new Entry(); + $owner->sectionId = $this->section->id; + $owner->typeId = $this->ownerEntryType->id; + $owner->title = 'Reorder Test Owner Entry'; + $owner->setFieldValue('reorderTestMatrix', [ + 'new1' => ['type' => 'reorderTestBlock', 'fields' => ['blockText' => 'First']], + 'new2' => ['type' => 'reorderTestBlock', 'fields' => ['blockText' => 'Second']], + 'new3' => ['type' => 'reorderTestBlock', 'fields' => ['blockText' => 'Third']], + ]); + + if (!$this->elements->saveElement($owner)) { + throw new RuntimeException('Could not save owner entry: ' . implode(', ', $owner->getFirstErrors())); + } + + $nested = Entry::find() + ->ownerId($owner->id) + ->siteId($owner->siteId) + ->status(null) + ->orderBy(['elements_owners.sortOrder' => SORT_ASC]) + ->all(); + self::assertSame(['First', 'Second', 'Third'], array_map(fn(Entry $e) => $e->getFieldValue('blockText'), $nested)); + + // Move "Third" to the front + $this->elements->reorderNestedElements($owner, $owner->getFieldValue('reorderTestMatrix'), [$nested[2]->id], 0); + + $reordered = Entry::find() + ->ownerId($owner->id) + ->siteId($owner->siteId) + ->status(null) + ->orderBy(['elements_owners.sortOrder' => SORT_ASC]) + ->all(); + self::assertSame(['Third', 'First', 'Second'], array_map(fn(Entry $e) => $e->getFieldValue('blockText'), $reordered)); } }