diff --git a/composer.json b/composer.json index 0c71c625996..e8a61c42723 100644 --- a/composer.json +++ b/composer.json @@ -66,7 +66,7 @@ "symfony/yaml": "^7.0|^8.0", "theiconic/name-parser": "^1.2", "tpetry/laravel-query-expressions": "^1.5", - "twig/twig": "~3.27.0", + "twig/twig": "~3.28.0", "voku/portable-ascii": "^2.0", "web-auth/webauthn-lib": "~5.3.5", "webonyx/graphql-php": "~15.33.1", diff --git a/composer.lock b/composer.lock index 13f50500cec..750d7220712 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": "2807de607d33d8d3f13bd7f0562e4ad1", + "content-hash": "84796e77430ac384e8ac609e35d63e9d", "packages": [ { "name": "bacon/bacon-qr-code", @@ -411,7 +411,7 @@ }, { "name": "craftcms/cms-assets", - "version": "6.x-dev", + "version": "dev-6.x-5.11", "dist": { "type": "path", "url": "./cms-assets", @@ -8669,16 +8669,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": { @@ -8733,7 +8733,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": [ { @@ -8745,7 +8745,7 @@ "type": "tidelift" } ], - "time": "2026-05-30T17:09:26+00:00" + "time": "2026-07-03T20:44:34+00:00" }, { "name": "vlucas/phpdotenv", diff --git a/routes/web.php b/routes/web.php index 465f664ee10..6d02f47dec1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Http\Middleware\RequireEdition; use CraftCms\Cms\Route\Routes as CraftRoutes; use CraftCms\Cms\Site\Sites; +use Illuminate\Http\JsonResponse; use Illuminate\Support\Facades\Route; $routes = app(CraftRoutes::class); @@ -72,4 +73,7 @@ }); } +// Signals support for passkeys without leaking the CP URL, per https://www.w3.org/TR/passkey-endpoints/. +Route::get('.well-known/passkey-endpoints', fn () => new JsonResponse((object) [])); + Route::fallback(SiteRouteController::class)->name('siteFallback'); diff --git a/src/Asset/Elements/Asset.php b/src/Asset/Elements/Asset.php index b4500051ec8..e2cab36ada2 100644 --- a/src/Asset/Elements/Asset.php +++ b/src/Asset/Elements/Asset.php @@ -338,12 +338,6 @@ class Asset extends Element public function __construct($config = []) { - // alt='' actually means something, so we should preserve it. - $alt = Arr::pull($config, 'alt'); - if ($alt !== null) { - $this->alt = $alt; - } - parent::__construct($config); if (isset($this->alt)) { @@ -1176,18 +1170,6 @@ public function __get(string $name): mixed } } - /** @param array|null> $values */ - #[Override] - public function setAttributesFromRequest(array $values): void - { - // alt='' actually means something, so we should preserve it. - if (Arr::has($values, 'alt')) { - $this->alt = Arr::pull($values, 'alt') ?? ''; - } - - parent::setAttributesFromRequest($values); - } - /** * Returns the volume’s ID. */ @@ -3158,10 +3140,6 @@ public function afterSave(bool $isNew): void $model->mimeType = $this->_mimeType; } - if ($model->alt === null) { - $model->alt = $this->alt; - } - if ($this->getHasFocalPoint()) { $focal = $this->getFocalPoint(); $model->focalPoint = number_format($focal['x'], 4).';'.number_format($focal['y'], 4); @@ -3170,8 +3148,17 @@ public function afterSave(bool $isNew): void } $model->save(); + + // we're not propagating at this point, so save the alt ONLY against the site we're saving to + DB::table(Table::ASSETS_SITES) + ->upsert([ + 'assetId' => $this->id, + 'siteId' => $this->siteId, + 'alt' => $this->alt, + ], ['assetId', 'siteId']); } + $upsert = false; if ( $this->propagating && $this->propagatingFrom && @@ -3184,16 +3171,19 @@ public function afterSave(bool $isNew): void $this->alt !== $from->alt && $this->getAltTranslationKey() === $from->getAltTranslationKey() ) { + $upsert = true; $this->alt = $from->alt; } } - DB::table(Table::ASSETS_SITES) - ->upsert([ - 'assetId' => $this->id, - 'siteId' => $this->siteId, - 'alt' => $this->alt, - ], ['assetId', 'siteId']); + if ($upsert || $this->propagateAll) { + DB::table(Table::ASSETS_SITES) + ->upsert([ + 'assetId' => $this->id, + 'siteId' => $this->siteId, + 'alt' => $this->alt, + ], ['assetId', 'siteId']); + } parent::afterSave($isNew); } diff --git a/src/Cms.php b/src/Cms.php index 4b01647da9e..68a0822656c 100644 --- a/src/Cms.php +++ b/src/Cms.php @@ -30,7 +30,7 @@ public const string VERSION = '6.0.0-alpha.16'; - public const string SCHEMA_VERSION = '6.0.0.4'; + public const string SCHEMA_VERSION = '6.0.0.6'; public const string MIN_VERSION_REQUIRED = '5.9.0'; diff --git a/src/Database/Migrations/2026_06_10_112441_drop_assets_alt_column.php b/src/Database/Migrations/2026_06_10_112441_drop_assets_alt_column.php new file mode 100644 index 00000000000..e1aedc86eb4 --- /dev/null +++ b/src/Database/Migrations/2026_06_10_112441_drop_assets_alt_column.php @@ -0,0 +1,66 @@ +select(['assets.id', 'elements_sites.siteId', 'assets.alt', 'assets_sites.assetId as assetSiteId']) + ->join(Table::ELEMENTS_SITES, 'elements_sites.elementId', '=', 'assets.id') + ->leftJoin(Table::ASSETS_SITES, function ($join) { + $join->on('assets_sites.assetId', '=', 'assets.id') + ->on('assets_sites.siteId', '=', 'elements_sites.siteId'); + }) + ->whereNotNull('assets.alt') + ->where('assets.alt', '!=', '') + ->whereNull('assets_sites.alt') + ->get(); + + foreach ($rows as $row) { + // If the assets_sites.assetId value came back, the row already exists + if ($row->assetSiteId !== null) { + DB::table(Table::ASSETS_SITES) + ->where('assetId', $row->id) + ->where('siteId', $row->siteId) + ->update(['alt' => $row->alt]); + } else { + DB::table(Table::ASSETS_SITES)->insert([ + 'assetId' => $row->id, + 'siteId' => $row->siteId, + 'alt' => $row->alt, + ]); + } + } + + Schema::table(Table::ASSETS, function (Blueprint $table) { + $table->dropColumn('alt'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + $this->output->error('2026_06_10_112441_drop_assets_alt_column cannot be reverted.'); + } +}; diff --git a/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php b/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php new file mode 100644 index 00000000000..97d773676a4 --- /dev/null +++ b/src/Database/Migrations/2026_08_12_143000_drop_elementactivity_draftid_fk.php @@ -0,0 +1,34 @@ +first(fn (array $foreignKey) => in_array('draftId', $foreignKey['columns'], true)); + + if (! $foreignKey) { + return; + } + + Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->dropForeign($foreignKey['name'])); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->foreign('draftId')->references('id')->on(Table::DRAFTS)->cascadeOnDelete()); + } +}; diff --git a/src/Database/Migrations/Install.php b/src/Database/Migrations/Install.php index 0748b9d3c19..f59d0d29f53 100644 --- a/src/Database/Migrations/Install.php +++ b/src/Database/Migrations/Install.php @@ -286,7 +286,6 @@ public function createTables(?Logger $logger = null): void $table->string('filename'); $table->string('mimeType')->nullable(); $table->string('kind', 50)->default(FileKind::Unknown->value); - $table->text('alt')->nullable(); $table->unsignedInteger('width')->nullable(); $table->unsignedInteger('height')->nullable(); $table->unsignedBigInteger('size')->nullable(); @@ -1206,7 +1205,6 @@ public function addForeignKeys(): void Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->foreign('elementId')->references('id')->on(Table::ELEMENTS)->cascadeOnDelete()); Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->foreign('userId')->references('id')->on(Table::USERS)->cascadeOnDelete()); Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->foreign('siteId')->references('id')->on(Table::SITES)->cascadeOnDelete()); - Schema::table(Table::ELEMENTACTIVITY, fn (Blueprint $table) => $table->foreign('draftId')->references('id')->on(Table::DRAFTS)->cascadeOnDelete()); Schema::table(Table::ELEMENTS, fn (Blueprint $table) => $table->foreign('canonicalId')->references('id')->on(Table::ELEMENTS)->nullOnDelete()); Schema::table(Table::ELEMENTS, fn (Blueprint $table) => $table->foreign('draftId')->references('id')->on(Table::DRAFTS)->cascadeOnDelete()); Schema::table(Table::ELEMENTS, fn (Blueprint $table) => $table->foreign('revisionId')->references('id')->on(Table::REVISIONS)->cascadeOnDelete()); diff --git a/src/Element/Elements.php b/src/Element/Elements.php index 5f6b858721c..0f6750cae49 100644 --- a/src/Element/Elements.php +++ b/src/Element/Elements.php @@ -7,6 +7,7 @@ use CraftCms\Cms\Component\ComponentHelper; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Element\Contracts\NestedElementInterface; use CraftCms\Cms\Element\Data\EagerLoadPlan; use CraftCms\Cms\Element\Exceptions\InvalidElementException; use CraftCms\Cms\Element\Exceptions\UnsupportedSiteException; @@ -49,6 +50,7 @@ class Elements public function __construct( private readonly ElementPlaceholders $placeholders, private readonly ElementTypes $elementTypes, + private readonly ElementCaches $elementCaches, ) {} /** @@ -717,6 +719,58 @@ public function restoreElements(array $elements): bool return app(ElementDeletions::class)->restoreElements($elements); } + /** + * Reorders nested elements for a given owner element. + * + * @param ElementInterface $owner The owner element + * @param ElementQueryInterface|ElementCollection $nestedElements The owner’s nested elements + * @param int[] $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 + */ + 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']) + ->pluck('sortOrder', 'id') + ->all(); + } 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::table(Table::ELEMENTS_OWNERS) + ->where('ownerId', $owner->id) + ->where('elementId', $id) + ->update([ + 'sortOrder' => $sortOrder, + ]); + } + } + + $this->elementCaches->invalidateForElement($owner); + } + // Misc // ------------------------------------------------------------------------- diff --git a/src/Element/Queries/AssetQuery.php b/src/Element/Queries/AssetQuery.php index 950e029be4e..cd2813b2737 100644 --- a/src/Element/Queries/AssetQuery.php +++ b/src/Element/Queries/AssetQuery.php @@ -73,7 +73,6 @@ public function __construct(array $config = []) 'assets.width as width', 'assets.height as height', 'assets.size as size', - 'assets.alt as alt', 'assets.focalPoint as focalPoint', 'assets.keptFile as keptFile', 'assets.dateModified as dateModified', @@ -193,12 +192,9 @@ private function applyAuthParam(?bool $value, string $permissionPrefix, string $ #[Override] public function createElement(array $row): ElementInterface { - // Use the site-specific alt text, if set + // Use the site-specific alt text $siteAlt = Arr::pull($row, 'siteAlt'); - - if ($siteAlt !== null) { - $row['alt'] = $siteAlt; - } + $row['alt'] = $siteAlt; return parent::createElement($row); } diff --git a/src/Element/Queries/Concerns/Asset/QueriesAlt.php b/src/Element/Queries/Concerns/Asset/QueriesAlt.php index 5e2587e5fdf..1d9ff76b51b 100644 --- a/src/Element/Queries/Concerns/Asset/QueriesAlt.php +++ b/src/Element/Queries/Concerns/Asset/QueriesAlt.php @@ -26,27 +26,17 @@ protected function initQueriesAlt(): void return; } - $hasAltCondition = function (Builder $query) { - $query->where('assets_sites.alt', '!=', '') - ->orWhere(function (Builder $query) { - $query->whereNull('assets_sites.alt') - ->where('assets.alt', '!=', '') - ->whereNotNull('assets.alt'); - }); - }; - - $withoutAltCondition = function (Builder $query) { - $query->where('assets_sites.alt', '=', '') - ->orWhere(function (Builder $query) { - $query->whereNull('assets_sites.alt') - ->where(function (Builder $query) { - $query->where('assets.alt', '=', '') - ->orWhereNull('assets.alt'); - }); - }); - }; - - $assetQuery->where($this->hasAlt ? $hasAltCondition : $withoutAltCondition); + if ($this->hasAlt) { + $assetQuery->where(function (Builder $query) { + $query->where('assets_sites.alt', '!=', '') + ->whereNotNull('assets_sites.alt'); + }); + } else { + $assetQuery->where(function (Builder $query) { + $query->where('assets_sites.alt', '=', '') + ->orWhereNull('assets_sites.alt'); + }); + } }); } diff --git a/src/Field/Data/LinkData.php b/src/Field/Data/LinkData.php index d2860e636ec..5e918eeb4fb 100644 --- a/src/Field/Data/LinkData.php +++ b/src/Field/Data/LinkData.php @@ -14,10 +14,12 @@ use CraftCms\Cms\Support\Html; use CraftCms\Cms\Support\Template; use CraftCms\Cms\Twig\Attributes\AllowedInSandbox; +use Illuminate\Contracts\Support\Arrayable; use Stringable; use Twig\Markup; -class LinkData implements Serializable, Stringable +/** @implements Arrayable */ +class LinkData implements Arrayable, Serializable, Stringable { /** @var string|null The link’s URL suffix value. */ #[AllowedInSandbox] @@ -248,4 +250,30 @@ public function serialize(): array 'filename' => $this->filename, ]); } + + public function toArray(): array + { + return [ + 'type' => $this->getType(), + 'value' => $this->getValue(), + 'url' => $this->getUrl(), + 'label' => $this->getLabel(), + 'filename' => $this->getFilename(), + 'link' => (string) $this->getLink(), + 'attributes' => $this->getAttributes(), + 'defaultLabel' => $this->getLabel(false), + 'urlSuffix' => $this->urlSuffix, + 'target' => $this->target, + 'title' => $this->title, + 'class' => $this->class, + 'id' => $this->id, + 'rel' => $this->rel, + 'ariaLabel' => $this->ariaLabel, + 'download' => $this->download, + 'elementType' => $this->getElement() !== null ? $this->getElement()::class : null, + 'elementId' => $this->getElement()?->id, + 'elementSiteId' => $this->getElement()?->siteId, + 'elementTitle' => $this->getElement() !== null ? (string) $this->getElement() : null, + ]; + } } diff --git a/src/Http/Controllers/Auth/LoginController.php b/src/Http/Controllers/Auth/LoginController.php index 713928d7dd5..933a59f4ef0 100644 --- a/src/Http/Controllers/Auth/LoginController.php +++ b/src/Http/Controllers/Auth/LoginController.php @@ -40,8 +40,9 @@ { public function showLogin(Request $request, GeneralConfig $generalConfig, AuthMethods $authMethods, OAuth $oauth): Response|View|\Inertia\Response { - // see if they're already logged in - if ($user = $request->craftUser()) { + // see if they're already logged in, unless this is a preview request + // (see https://github.com/craftcms/cms/discussions/19360) + if (! $request->isPreview() && ($user = $request->craftUser())) { return $this->handleSuccessfulLogin($request, $user); } diff --git a/src/Http/Controllers/NestedElementsController.php b/src/Http/Controllers/NestedElementsController.php index 1f671a53455..ca5e2e66b9f 100644 --- a/src/Http/Controllers/NestedElementsController.php +++ b/src/Http/Controllers/NestedElementsController.php @@ -5,11 +5,7 @@ namespace CraftCms\Cms\Http\Controllers; use CraftCms\Cms\Database\Table; -use CraftCms\Cms\Element\Contracts\ElementInterface; -use CraftCms\Cms\Element\Contracts\NestedElementInterface; -use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\Elements; -use CraftCms\Cms\Element\Queries\Contracts\ElementQueryInterface; use CraftCms\Cms\Http\Requests\NestedElementsRequest; use CraftCms\Cms\Http\RespondsWithFlash; use Illuminate\Support\Facades\DB; @@ -26,45 +22,16 @@ public function __construct( private Elements $elements, ) {} - public function reorder(NestedElementsRequest $request, ElementCaches $elementCaches): Response + public function reorder(NestedElementsRequest $request): Response { $request->authorizeReorder(); - $nestedElements = $request->nestedElements(); - - if ($nestedElements instanceof ElementQueryInterface) { - $oldSortOrders = (clone $nestedElements) - ->status(null) - ->asArray() - ->select(['id', 'sortOrder']) - ->pluck('sortOrder', 'id') - ->all(); - } 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), $request->elementIds()); - array_splice($allIds, $request->offset(), 0, $request->elementIds()); - - // Update all the incorrect sort orders - foreach ($allIds as $i => $id) { - $sortOrder = $i + 1; - if (! isset($oldSortOrders[$id]) || $sortOrder !== $oldSortOrders[$id]) { - DB::table(Table::ELEMENTS_OWNERS) - ->where('ownerId', $request->owner()->id) - ->where('elementId', $id) - ->update([ - 'sortOrder' => $sortOrder, - ]); - } - } - - $elementCaches->invalidateForElement($request->owner()); + $this->elements->reorderNestedElements( + $request->owner(), + $request->nestedElements(), + $request->elementIds(), + $request->offset(), + ); return $this->asSuccess(t('New {total, plural, =1{position} other{positions}} saved.', [ 'total' => count($request->elementIds()), diff --git a/src/Support/Url.php b/src/Support/Url.php index dd8f944e88a..83f78eb4565 100644 --- a/src/Support/Url.php +++ b/src/Support/Url.php @@ -133,22 +133,52 @@ public static function urlWithParams(string $url, array|string $params): string * Removes a query string param from a URL. */ public static function removeParam(string $url, string $param): string + { + return static::removeParams($url, [$param]); + } + + /** + * Removes query string params from a URL. + * + * @param string[] $params + */ + public static function removeParams(string $url, array $params): string { // Extract any params/fragment from the base URL - [$url, $params, $fragment] = self::_extractParams($url); + [$url, $urlParams, $fragment] = self::_extractParams($url); - // Remove the param - unset($params[$param]); + // Remove the params + foreach ($params as $param) { + unset($urlParams[$param]); + } // Rebuild - if (($query = static::buildQuery($params)) !== '') { - $url .= '?'.$query; - } - if ($fragment !== null) { - $url .= '#'.$fragment; + return self::_buildUrl($url, $urlParams, $fragment); + } + + /** + * Removes all query string params from a URL. + * + * @param string[] $except Any params that should be left alone + */ + 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 params + if (! empty($except)) { + foreach (array_keys($params) as $param) { + if (! in_array($param, $except)) { + unset($params[$param]); + } + } + } else { + $params = []; } - return $url; + // Rebuild + return self::_buildUrl($url, $params, $fragment); } /** @@ -244,14 +274,18 @@ public static function rootRelativeUrl(string $url): string /** * Returns either a control panel or a site URL, depending on the request type. + * + * @param array|string|false|null $params The query params to add to the URL. If `false`, any existing params will be removed. */ - /** @param array|string|null $params */ - public static function url(string $path = '', array|string|null $params = null, ?string $scheme = null): string + /** @param array|string|false|null $params */ + public static function url(string $path = '', array|string|false|null $params = null, ?string $scheme = 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) { @@ -276,7 +310,7 @@ public static function url(string $path = '', array|string|null $params = null, $scheme = 'https'; } - return self::_createUrl($path, $params, $scheme, $cpUrl); + return self::_createUrl($path, $params ?: null, $scheme, $cpUrl); } /** diff --git a/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles.snap b/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles.snap index a191d884317..8e805edeaa2 100644 --- a/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles.snap +++ b/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles.snap @@ -78,7 +78,8 @@ foreach ($context['_seq'] as $context["_key"] => $context["item"]) { } $_parent = $context['_parent']; unset($context['_seq'], $context['_key'], $context['item'], $context['_parent'], $context['loop']); -$context = array_intersect_key($context, $_parent) + $_parent; +$context = array_intersect_key($context, $_parent); +$context += $_parent; if (isset($_thisItemLevel)) { $_tmpContext = $context; if ($_thisItemLevel > $_firstItemLevel) { diff --git a/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles_without_lower_body.snap b/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles_without_lower_body.snap index f7d1c97d53c..7aa1fb0ffc0 100644 --- a/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles_without_lower_body.snap +++ b/tests/.pest/snapshots/Unit/Twig/Nodes/NavNodeTest/it_compiles_without_lower_body.snap @@ -76,7 +76,8 @@ foreach ($context['_seq'] as $context["_key"] => $context["item"]) { } $_parent = $context['_parent']; unset($context['_seq'], $context['_key'], $context['item'], $context['_parent'], $context['loop']); -$context = array_intersect_key($context, $_parent) + $_parent; +$context = array_intersect_key($context, $_parent); +$context += $_parent; if (isset($_thisItemLevel)) { $_tmpContext = $context; if ($_thisItemLevel > $_firstItemLevel) { diff --git a/tests/Feature/Element/Queries/Concerns/Asset/QueriesAltTest.php b/tests/Feature/Element/Queries/Concerns/Asset/QueriesAltTest.php index 17e8aee7a69..1fb7c45e08c 100644 --- a/tests/Feature/Element/Queries/Concerns/Asset/QueriesAltTest.php +++ b/tests/Feature/Element/Queries/Concerns/Asset/QueriesAltTest.php @@ -8,7 +8,7 @@ Asset::factory()->create(); // With alt - Asset::factory()->create([ + Asset::factory()->create()->sites()->attach(Site::all(), [ 'alt' => 'Alt text', ]); diff --git a/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php b/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php index 321a3dd09cb..60ea93fa6d4 100644 --- a/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/DeleteElementsControllerTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\DeletionBlockers\BaseDeletionBlocker; +use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\ElementCollection; use CraftCms\Cms\Element\Elements as ElementsService; use CraftCms\Cms\Element\ElementTypes; @@ -141,14 +142,15 @@ ->all(); app()->bind(ElementsService::class, function () use (&$deletedIds) { - return new class(app(ElementPlaceholders::class), app(ElementTypes::class), $deletedIds) extends ElementsService + return new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class), $deletedIds) extends ElementsService { public function __construct( ElementPlaceholders $placeholders, ElementTypes $elementTypes, + ElementCaches $elementCaches, private array &$deletedIds, ) { - parent::__construct($placeholders, $elementTypes); + parent::__construct($placeholders, $elementTypes, $elementCaches); } public function deleteElement(ElementInterface $element, bool $hard = false): bool diff --git a/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php b/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php index 65b8f24f648..01ef858ce13 100644 --- a/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SaveElementControllerTest.php @@ -10,6 +10,7 @@ use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Contracts\NestedElementInterface; use CraftCms\Cms\Element\Drafts; +use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\Elements as ElementsService; use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Enums\ElementActivityType; @@ -308,7 +309,7 @@ function createSaveElementMatrixFixture(): array ]); $entry->errors()->add('title', 'Title is invalid.'); - app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService + app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends ElementsService { public function saveElement( ElementInterface $element, @@ -347,7 +348,7 @@ public function saveElement( 'slug' => 'canonical-title', ]); - app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService + app()->instance(ElementsService::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends ElementsService { public function saveElement( ElementInterface $element, @@ -387,7 +388,7 @@ public function saveElement( app(Drafts::class)->createDraft($entry, auth()->id(), provisional: true); actingAs(UserModel::findOrFail(auth()->id())); - $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends ElementsService { public ?bool $capturedCrossSiteValidate = null; @@ -461,11 +462,12 @@ public function saveElement( $volume = Volume::factory()->create(['fs' => 'disk:save-element-controller-test']); $folder = VolumeFolder::factory()->create(['volumeId' => $volume->id]); - $asset = AssetModel::factory()->createElement([ + $assetModel = AssetModel::factory()->create([ 'volumeId' => $volume->id, 'folderId' => $folder->id, - 'alt' => 'Existing alt text', ]); + $asset = Asset::find()->id($assetModel->id)->one(); + $assetModel->sites()->attach($asset->siteId, ['alt' => 'Existing alt text']); postJson(action([SaveElementController::class, 'store']), [ 'elementType' => Asset::class, @@ -474,13 +476,14 @@ public function saveElement( 'alt' => '', ])->assertOk(); - expect(Asset::find()->id($asset->id)->one()->alt)->toBe(''); + // Laravel's ConvertEmptyStringsToNull middleware turns the posted '' into null before it reaches the element. + expect(Asset::find()->id($asset->id)->one()->alt)->toBeNull(); }); it('marks nested elements to update their owner search index before saving', function () { $fixture = createSaveElementMatrixFixture(); - $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends ElementsService { public bool $capturedNestedOwnerIndexFlag = false; @@ -643,7 +646,7 @@ public function saveElement( ->where('id', $fixture['draftBlock']->id) ->update(['primaryOwnerId' => $fixture['owner']->id]); - $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends ElementsService { public int $saveCalls = 0; @@ -695,7 +698,7 @@ public function saveElement( ->where('id', $fixture['draftBlock']->id) ->update(['primaryOwnerId' => $fixture['owner']->id]); - $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends ElementsService + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends ElementsService { public int $saveCalls = 0; diff --git a/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php b/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php index 503c9ff267b..b9a4a6104d0 100644 --- a/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SaveElementIndexElementsControllerTest.php @@ -5,6 +5,7 @@ use CraftCms\Cms\Cms; use CraftCms\Cms\Database\Table; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Operations\ElementPlaceholders; @@ -182,14 +183,15 @@ 'title' => 'Second Before Save', ]); - app()->instance(Elements::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class), $secondEntry->id) extends Elements + app()->instance(Elements::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class), $secondEntry->id) extends Elements { public function __construct( ElementPlaceholders $placeholders, ElementTypes $elementTypes, + ElementCaches $elementCaches, private readonly int $failingElementId, ) { - parent::__construct($placeholders, $elementTypes); + parent::__construct($placeholders, $elementTypes, $elementCaches); } public function saveElement( diff --git a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php index 3a11ce22e0a..cd218e9759e 100644 --- a/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php +++ b/tests/Feature/Http/Controllers/Elements/SearchControllerTest.php @@ -8,6 +8,7 @@ use CraftCms\Cms\Element\Conditions\ElementCondition; use CraftCms\Cms\Element\Conditions\IdConditionRule; use CraftCms\Cms\Element\Contracts\ElementInterface; +use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Operations\ElementPlaceholders; @@ -296,7 +297,7 @@ public function modifyQuery(ElementQueryInterface $query): void } }; - $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), $referenceEntry) extends Elements + $elements = new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class), $referenceEntry) extends Elements { public ?int $requestedElementId = null; @@ -307,9 +308,10 @@ public function modifyQuery(ElementQueryInterface $query): void public function __construct( ElementPlaceholders $placeholders, ElementTypes $elementTypes, + ElementCaches $elementCaches, private readonly Entry $referenceEntry, ) { - parent::__construct($placeholders, $elementTypes); + parent::__construct($placeholders, $elementTypes, $elementCaches); } public function getElementById( diff --git a/tests/Feature/Http/Controllers/MatrixControllerTest.php b/tests/Feature/Http/Controllers/MatrixControllerTest.php index 7ceb3240bcd..c2d946a5daf 100644 --- a/tests/Feature/Http/Controllers/MatrixControllerTest.php +++ b/tests/Feature/Http/Controllers/MatrixControllerTest.php @@ -4,6 +4,7 @@ use CraftCms\Cms\Element\Contracts\ElementInterface; use CraftCms\Cms\Element\Drafts; +use CraftCms\Cms\Element\ElementCaches; use CraftCms\Cms\Element\Elements; use CraftCms\Cms\Element\ElementTypes; use CraftCms\Cms\Element\Exceptions\InvalidElementException; @@ -370,7 +371,7 @@ public function saveElementAsDraft(ElementInterface $element, ?int $creatorId = $this->fixture = refreshMatrixControllerFixture($this->fixture); $source = matrixControllerNestedEntries($this->fixture)->sole(); - app()->instance(Elements::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class)) extends Elements + app()->instance(Elements::class, new class(app(ElementPlaceholders::class), app(ElementTypes::class), app(ElementCaches::class)) extends Elements { public function duplicateElement( ElementInterface $element, diff --git a/tests/Feature/Http/Controllers/NestedElementsControllerTest.php b/tests/Feature/Http/Controllers/NestedElementsControllerTest.php index 4b4cf682a0c..dd849614fad 100644 --- a/tests/Feature/Http/Controllers/NestedElementsControllerTest.php +++ b/tests/Feature/Http/Controllers/NestedElementsControllerTest.php @@ -14,6 +14,7 @@ use CraftCms\Cms\Field\Matrix; use CraftCms\Cms\Http\Controllers\NestedElementsController; use CraftCms\Cms\Section\Models\Section as SectionModel; +use CraftCms\Cms\Support\Facades\Elements; use CraftCms\Cms\Support\Facades\Fields; use CraftCms\Cms\Support\Facades\Sections; use CraftCms\Cms\User\Elements\User; @@ -241,6 +242,22 @@ function nestedElementsControllerOwnerSortOrders(int $ownerId): array ]); }); +it('reorders nested elements via Elements::reorderNestedElements() directly', function () { + ['owner' => $owner, 'field' => $field, 'entryType' => $entryType] = nestedElementsControllerCreateMatrixOwnerFixture(); + + $first = nestedElementsControllerCreateMatrixNestedEntry($owner, $field, $entryType, 1, 'First'); + $second = nestedElementsControllerCreateMatrixNestedEntry($owner, $field, $entryType, 2, 'Second'); + $third = nestedElementsControllerCreateMatrixNestedEntry($owner, $field, $entryType, 3, 'Third'); + + Elements::reorderNestedElements($owner, $owner->{'field:matrixField'}, [$third->id], 0); + + expect(nestedElementsControllerOwnerSortOrders($owner->id))->toBe([ + $third->id => 1, + $first->id => 2, + $second->id => 3, + ]); +}); + it('validates destroy params', function () { $owner = User::findOne(); diff --git a/tests/Feature/Route/WellKnownEndpointsTest.php b/tests/Feature/Route/WellKnownEndpointsTest.php new file mode 100644 index 00000000000..56f61b3f514 --- /dev/null +++ b/tests/Feature/Route/WellKnownEndpointsTest.php @@ -0,0 +1,12 @@ +assertOk() + ->assertHeader('content-type', 'application/json') + ->assertContent('{}'); +}); diff --git a/tests/Feature/Support/URLTest.php b/tests/Feature/Support/URLTest.php index bc0f39a694f..4450bd87300 100644 --- a/tests/Feature/Support/URLTest.php +++ b/tests/Feature/Support/URLTest.php @@ -216,6 +216,61 @@ ], ]); + test('removes multiple query params from a URL', function (string $expected, string $url, array $params) { + expect(Url::removeParams($url, $params))->toBe($expected); + })->with([ + 'removes-multiple' => [ + 'https://craftcms.com/?bar=2#anchor', + 'https://craftcms.com/?foo=1&bar=2&baz=3#anchor', + ['foo', 'baz'], + ], + 'removes-all-listed' => [ + 'https://craftcms.com/#anchor', + 'https://craftcms.com/?foo=1&bar=2#anchor', + ['foo', 'bar'], + ], + 'keeps-url-when-params-are-missing' => [ + 'https://craftcms.com/?foo=1', + 'https://craftcms.com/?foo=1', + ['bar', 'baz'], + ], + 'empty-params' => [ + 'https://craftcms.com/?foo=1', + 'https://craftcms.com/?foo=1', + [], + ], + ]); + + test('removes all query params from a URL', function (string $expected, string $url, array $except) { + expect(Url::removeAllParams($url, $except))->toBe($expected); + })->with([ + 'removes-all' => [ + 'https://craftcms.com/', + 'https://craftcms.com/?foo=1&bar=2', + [], + ], + 'keeps-fragment' => [ + 'https://craftcms.com/#anchor', + 'https://craftcms.com/?foo=1&bar=2#anchor', + [], + ], + 'except' => [ + 'https://craftcms.com/?bar=2', + 'https://craftcms.com/?foo=1&bar=2&baz=3', + ['bar'], + ], + 'except-multiple' => [ + 'https://craftcms.com/?foo=1&baz=3', + 'https://craftcms.com/?foo=1&bar=2&baz=3', + ['foo', 'baz'], + ], + 'except-missing' => [ + 'https://craftcms.com/', + 'https://craftcms.com/?foo=1&bar=2', + ['baz'], + ], + ]); + test('adds token params to a URL', function (string $expected, string $url, string $token) { Cms::config()->useSslOnTokenizedUrls = true; @@ -390,6 +445,13 @@ ['{siteUrl}endpoint?returnUrl=https%3A%2F%2Fexample.test%2Fadmin%2Fentries%3Fsite%3D{handle}', 'endpoint', ['returnUrl' => 'https://example.test/admin/entries?site={handle}'], 'https', null], ]); + it('removes all params from a full URL when params is false', function () { + swapUrlRequest('https://localhost/news'); + + expect(Url::url('https://craftcms.com/?x-craft-preview=foo&test=bar', false)) + ->toBe('https://craftcms.com/'); + }); + it('creates action URLs', function () { swapUrlRequest('https://localhost/news'); diff --git a/yii2-adapter/legacy/helpers/UrlHelper.php b/yii2-adapter/legacy/helpers/UrlHelper.php index ddcd3dd8d14..b25700fe03e 100644 --- a/yii2-adapter/legacy/helpers/UrlHelper.php +++ b/yii2-adapter/legacy/helpers/UrlHelper.php @@ -1,6 +1,8 @@ + * * @since 3.0.0 * @deprecated 6.0.0 use {@see Url} instead. */ @@ -21,7 +24,6 @@ class UrlHelper extends Url /** * Returns a CP referral URL. * - * @return string|null * @since 5.9.0 * @deprecated in 5.10.0 */ @@ -40,7 +42,7 @@ 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; } diff --git a/yii2-adapter/legacy/web/DbSession.php b/yii2-adapter/legacy/web/DbSession.php new file mode 100644 index 00000000000..0935fa5e34a --- /dev/null +++ b/yii2-adapter/legacy/web/DbSession.php @@ -0,0 +1,37 @@ + + * + * @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); + } +}