From 564ba01dcb8910308fae1f382d64cbc190be496c Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 9 Jun 2026 08:37:33 -0700 Subject: [PATCH 1/8] 5.11 changelog [ci skip] --- CHANGELOG-5.11.md | 1 + 1 file changed, 1 insertion(+) create mode 100644 CHANGELOG-5.11.md diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md new file mode 100644 index 00000000000..519e1a53092 --- /dev/null +++ b/CHANGELOG-5.11.md @@ -0,0 +1 @@ +# Release Notes for Craft CMS 5.11 (WIP) From 827891785565bc60b4d4078628b781484fc872b6 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 16 Jun 2026 10:06:35 -0700 Subject: [PATCH 2/8] UrlHelper::removeParams(), removeAllParams() --- src/helpers/UrlHelper.php | 65 +++++++++-- tests/unit/helpers/UrlHelperTest.php | 165 ++++++++++++++++++++++++++- 2 files changed, 221 insertions(+), 9 deletions(-) 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/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, + ], ]; } From 95ef394198960be192514ce7c89d70a03eac7646 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 16 Jun 2026 10:24:31 -0700 Subject: [PATCH 3/8] Release notes [ci skip] --- CHANGELOG-5.11.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 519e1a53092..96bb423f326 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -1 +1,9 @@ # 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)) + +### Extensibility +- Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) +- 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)) From 45ac9aa47a73a0c883f861c793b2c781854a13e9 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Thu, 25 Jun 2026 08:05:59 -0600 Subject: [PATCH 4/8] craft\web\DbSession Resolves #19139 --- CHANGELOG-5.11.md | 1 + src/web/DbSession.php | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) create mode 100644 src/web/DbSession.php diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 96bb423f326..44d94590972 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -2,6 +2,7 @@ ### 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::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) 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); + } +} From 0ccf9a747c0ccae25796a363b9e3df00ca9c2e49 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 6 Jul 2026 10:49:11 -0700 Subject: [PATCH 5/8] Twig 3.28 --- composer.json | 2 +- composer.lock | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/composer.json b/composer.json index c2def279d9c..13984bf073b 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.2.4", "yiisoft/yii2": "~2.0.55.0", diff --git a/composer.lock b/composer.lock index ca23fd63835..91f5e137d57 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": "4c5e60c5404c43b6fe24ba3f940b4d5e", + "content-hash": "1f92d9154a7a6c259b9b915d236d014a", "packages": [ { "name": "bacon/bacon-qr-code", @@ -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", From dd9b6f18513e1d9e1f0419d1325eb3186e7f305a Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Mon, 6 Jul 2026 10:50:00 -0700 Subject: [PATCH 6/8] Release note [ci skip] --- CHANGELOG-5.11.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 44d94590972..27230bd1f98 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -8,3 +8,6 @@ - Added `craft\helpers\UrlHelper::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - Added `craft\helpers\UrlHelper::removeAllParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - 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. From ad1ee56e12ebdeb66631f3e10ebef4176c55b56d Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 28 Jul 2026 14:07:38 -0400 Subject: [PATCH 7/8] Sort lines [ci skip] --- CHANGELOG-5.11.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index 27230bd1f98..b2285eaf507 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -5,8 +5,8 @@ - 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::removeParams()`. ([#19102](https://github.com/craftcms/cms/pull/19102)) - 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)) - 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 From 2ba19b35238aa6075571a80ea73924964a10c019 Mon Sep 17 00:00:00 2001 From: brandonkelly Date: Tue, 28 Jul 2026 14:22:19 -0400 Subject: [PATCH 8/8] reorderNestedElements() --- CHANGELOG-5.11.md | 1 + src/controllers/NestedElementsController.php | 34 +---- src/services/Elements.php | 52 +++++++ tests/unit/services/ElementsTest.php | 147 +++++++++++++++++++ 4 files changed, 201 insertions(+), 33 deletions(-) diff --git a/CHANGELOG-5.11.md b/CHANGELOG-5.11.md index b2285eaf507..a51212998a5 100644 --- a/CHANGELOG-5.11.md +++ b/CHANGELOG-5.11.md @@ -7,6 +7,7 @@ ### 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 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/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/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)); } }