diff --git a/core/.phpstan-baseline.php b/core/.phpstan-baseline.php index 2a9e91999164272d26634bb88abf26e17be309d3..4e6b58164d48bfb4577eec2004422755c5def829 100644 --- a/core/.phpstan-baseline.php +++ b/core/.phpstan-baseline.php @@ -31992,24 +31992,6 @@ 'count' => 1, 'path' => __DIR__ . '/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php', ]; -$ignoreErrors[] = [ - // identifier: property.notFound - 'message' => '#^Access to an undefined property Drupal\\\\path\\\\Plugin\\\\Field\\\\FieldType\\\\PathItem\\:\\:\\$alias\\.$#', - 'count' => 3, - 'path' => __DIR__ . '/modules/path/src/Plugin/Field/FieldType/PathItem.php', -]; -$ignoreErrors[] = [ - // identifier: property.notFound - 'message' => '#^Access to an undefined property Drupal\\\\path\\\\Plugin\\\\Field\\\\FieldType\\\\PathItem\\:\\:\\$langcode\\.$#', - 'count' => 2, - 'path' => __DIR__ . '/modules/path/src/Plugin/Field/FieldType/PathItem.php', -]; -$ignoreErrors[] = [ - // identifier: property.notFound - 'message' => '#^Access to an undefined property Drupal\\\\path\\\\Plugin\\\\Field\\\\FieldType\\\\PathItem\\:\\:\\$pid\\.$#', - 'count' => 5, - 'path' => __DIR__ . '/modules/path/src/Plugin/Field/FieldType/PathItem.php', -]; $ignoreErrors[] = [ // identifier: return.missing 'message' => '#^Method Drupal\\\\path\\\\Plugin\\\\Field\\\\FieldType\\\\PathItem\\:\\:postSave\\(\\) should return bool but return statement is missing\\.$#', diff --git a/core/modules/jsonapi/tests/src/Functional/NodeTest.php b/core/modules/jsonapi/tests/src/Functional/NodeTest.php index c40b95ae58537005d340c5f511b09ac5a1ca6f77..1c5bdaa1220846a262c3d0760828fa635a61f095 100644 --- a/core/modules/jsonapi/tests/src/Functional/NodeTest.php +++ b/core/modules/jsonapi/tests/src/Functional/NodeTest.php @@ -173,9 +173,12 @@ protected function getExpectedDocument(): array { 'default_langcode' => TRUE, 'langcode' => 'en', 'path' => [ - 'alias' => '/llama', - 'pid' => 1, - 'langcode' => 'en', + [ + 'alias' => '/llama', + 'pid' => 1, + 'langcode' => 'en', + 'variant' => 'default', + ], ], 'promote' => TRUE, 'revision_timestamp' => '1973-11-29T21:33:09+00:00', @@ -294,7 +297,7 @@ public function testPatchPath(): void { $normalization = $this->getDocumentFromResponse($response); // Change node's path alias. - $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world'; + $normalization['data']['attributes']['path'][0]['alias'] .= 's-rule-the-world'; // Create node PATCH request. $request_options = $this->getAuthenticationRequestOptions(); diff --git a/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php b/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php index 6618515e3e59eb7e7852a05083bd3bfdb4fa30d6..ea89b45a610087d19d96a56337f338887749861c 100644 --- a/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php +++ b/core/modules/jsonapi/tests/src/Functional/PathAliasTest.php @@ -99,6 +99,7 @@ protected function getExpectedDocument(): array { 'self' => ['href' => $self_url->toString()], ], 'attributes' => [ + 'variant' => NULL, 'alias' => '/frontpage1', 'path' => '/<front>', 'langcode' => 'en', diff --git a/core/modules/jsonapi/tests/src/Functional/TermTest.php b/core/modules/jsonapi/tests/src/Functional/TermTest.php index c76de970eb0c608f0990c3036433d4b7255610a6..0b3602cd86ef9868222d0595904a28dfa3a5a6a3 100644 --- a/core/modules/jsonapi/tests/src/Functional/TermTest.php +++ b/core/modules/jsonapi/tests/src/Functional/TermTest.php @@ -274,9 +274,12 @@ protected function getExpectedDocument(): array { 'langcode' => 'en', 'name' => 'Llama', 'path' => [ - 'alias' => '/llama', - 'pid' => 1, - 'langcode' => 'en', + [ + 'alias' => '/llama', + 'pid' => 1, + 'langcode' => 'en', + 'variant' => 'default', + ], ], 'weight' => 0, 'drupal_internal__tid' => 1, @@ -418,7 +421,7 @@ public function testPatchPath(): void { $normalization = $this->getDocumentFromResponse($response); // Change term's path alias. - $normalization['data']['attributes']['path']['alias'] .= 's-rule-the-world'; + $normalization['data']['attributes']['path'][0]['alias'] .= 's-rule-the-world'; // Create term PATCH request. $request_options[RequestOptions::BODY] = Json::encode($normalization); diff --git a/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php b/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php index a8fe843e518b8dec4f559aa826e58a086cb3abdc..fe6431f415bbf358a563d204641e8af6476264e5 100644 --- a/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php +++ b/core/modules/node/tests/src/Functional/Rest/NodeResourceTestBase.php @@ -200,6 +200,7 @@ protected function getExpectedNormalizedEntity() { 'alias' => '/llama', 'pid' => 1, 'langcode' => 'en', + 'variant' => 'default', ], ], ]; diff --git a/core/modules/path/js/path.js b/core/modules/path/js/path.js index 9d55741b78d9b462e8c9eec1486c52e27ca9513b..deaa074d2259387f5241a114e718690a6aa86e70 100644 --- a/core/modules/path/js/path.js +++ b/core/modules/path/js/path.js @@ -16,9 +16,7 @@ $(context) .find('.path-form') .drupalSetSummary((context) => { - const pathElement = document.querySelector( - '.js-form-item-path-0-alias input', - ); + const pathElement = context.querySelector('.js-form-item input'); const path = pathElement && pathElement.value; return path ? Drupal.t('Alias: @alias', { '@alias': path }) diff --git a/core/modules/path/path.post_update.php b/core/modules/path/path.post_update.php index 10397185598b1933de7f3c2e9eaa96ab66ac1a9d..bf2f6808fbae3ec615c19b54d385e225a947eaac 100644 --- a/core/modules/path/path.post_update.php +++ b/core/modules/path/path.post_update.php @@ -13,3 +13,10 @@ function path_removed_post_updates(): array { 'path_post_update_create_language_content_settings' => '9.0.0', ]; } + +/** + * No-op update. + */ +function path_post_update_variant_reset_container(&$sandbox = NULL): void { + // No-op: force a container reset since services changed. +} diff --git a/core/modules/path/path.services.yml b/core/modules/path/path.services.yml new file mode 100644 index 0000000000000000000000000000000000000000..d593bd02b8c0044e6de839ed0f4d230a87dec981 --- /dev/null +++ b/core/modules/path/path.services.yml @@ -0,0 +1,9 @@ +services: + _defaults: + autoconfigure: true + autowire: true + + Drupal\path\PathVariant\PathVariantRepositoryInterface: + class: Drupal\path\PathVariant\PathVariantRepository + Drupal\path\EventSubscriber\EntityPathsEventSubscriber: ~ + Drupal\path\EventSubscriber\PathVariantEventSubscriber: ~ diff --git a/core/modules/path/src/Enum/PathVariantEnumInterface.php b/core/modules/path/src/Enum/PathVariantEnumInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..6759cb20a1b77d1ec3fa4ee9b2abaf0e1b3288d2 --- /dev/null +++ b/core/modules/path/src/Enum/PathVariantEnumInterface.php @@ -0,0 +1,16 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\Enum; + +interface PathVariantEnumInterface extends \UnitEnum { + + /** + * The machine name of a path variant. + * + * This is used in the PathAlias entities' `variant` field. + */ + public function getMachineName(): string; + +} diff --git a/core/modules/path/src/Event/EntityPathsEvent.php b/core/modules/path/src/Event/EntityPathsEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..dcca88a225a7d3d6bf4cfcdccc34b8a5a7ddba09 --- /dev/null +++ b/core/modules/path/src/Event/EntityPathsEvent.php @@ -0,0 +1,91 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\Event; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\path\PathVariant\PathVariant; +use Drupal\path\PathVariant\PlaceHolderInternalPath; + +/** + * Event for getting internal paths for an entity. + * + * @phpstan-type InternalPaths iterable<array{non-empty-string|\Drupal\path\PathVariant\PlaceHolderInternalPath, \Drupal\path\PathVariant\PathVariant}> + */ +final class EntityPathsEvent { + + /** + * @var list<array{non-empty-string, \Drupal\path\PathVariant\PathVariant}> + */ + private array $internalPaths = []; + + /** + * Constructs a new EntityPathsEvent. + */ + private function __construct( + private EntityInterface $entity, + ) { + } + + /** + * Factory for creating a new entity paths event. + * + * @internal + * Not for public use. + */ + public static function create( + EntityInterface $entity, + ): static { + return new static($entity, + ); + } + + /** + * Get the entity. + * + * @phpstan-pure + */ + public function getEntity(): EntityInterface { + return $this->entity; + } + + /** + * Adds an internal path. + * + * @param non-empty-string|\Drupal\path\PathVariant\PlaceHolderInternalPath $path + * A path. Must be prefixed by '/', or PlaceHolderInternalPath if the + * internal path cannot be determined yet. + * @param \Drupal\path\PathVariant\PathVariant $variant + * The variant. + * + * @return $this + * The object itself for chaining. + * + * @throws \InvalidArgumentException + * When path is invalid. + */ + public function addInternalPath(PlaceHolderInternalPath|string $path, PathVariant $variant): static { + if (\is_string($path) && FALSE === \str_starts_with($path, '/')) { + throw new \InvalidArgumentException('Path must be prefixed with forward slash.'); + } + + $this->internalPaths[] = [$path, $variant]; + + return $this; + } + + /** + * Get the internal path and variants for this entity. + * + * @return InternalPaths + * Internal paths and variants. + * + * @internal + * Not for public use. + */ + public function getInternalPaths(): iterable { + return $this->internalPaths; + } + +} diff --git a/core/modules/path/src/Event/PathVariantEvent.php b/core/modules/path/src/Event/PathVariantEvent.php new file mode 100644 index 0000000000000000000000000000000000000000..938c76d8c9333bb3cafb934324735bd3552e1fce --- /dev/null +++ b/core/modules/path/src/Event/PathVariantEvent.php @@ -0,0 +1,96 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\Event; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\path\PathVariant\PathVariant; + +/** + * Event for collecting path variants for an entity/bundle combination. + * + * @phpstan-type Variants iterable<\Drupal\path\PathVariant\PathVariant> + */ +final class PathVariantEvent { + + /** + * @var list<\Drupal\path\PathVariant\PathVariant> + */ + private array $variants = []; + + /** + * Constructs a new PathVariantEvent. + */ + private function __construct( + private string $entityTypeId, + private string $bundle, + ) { + } + + /** + * Factory for creating a new path variant event. + * + * @internal + * Not for public use. + */ + public static function create( + string $entityTypeId, + string $bundle, + ): static { + return new static($entityTypeId, $bundle); + } + + /** + * Factory for creating a new path variant event from an entity. + * + * @internal + * Not for public use. + */ + public static function createFromEntity(EntityInterface $entity): static { + return new static($entity->getEntityTypeId(), $entity->bundle()); + } + + /** + * Get the entity type ID. + * + * @phpstan-pure + */ + public function getEntityTypeId(): string { + return $this->entityTypeId; + } + + /** + * Get the bundle. + * + * @phpstan-pure + */ + public function getBundle(): string { + return $this->bundle; + } + + /** + * Adds a variant. + * + * @return $this + * The object itself for chaining. + */ + public function addVariant(PathVariant $variant): static { + $this->variants[] = $variant; + + return $this; + } + + /** + * Get the variants for this entity type and bundle. + * + * @return Variants + * + * @internal + * Not for public use. + */ + public function getVariants(): iterable { + return $this->variants; + } + +} diff --git a/core/modules/path/src/EventSubscriber/EntityPathsEventSubscriber.php b/core/modules/path/src/EventSubscriber/EntityPathsEventSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..e8236b7267c494cd43d9be8993de31e7168a570c --- /dev/null +++ b/core/modules/path/src/EventSubscriber/EntityPathsEventSubscriber.php @@ -0,0 +1,38 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\EventSubscriber; + +use Drupal\path\Event\EntityPathsEvent; +use Drupal\path\PathVariant\PathVariant; +use Drupal\path\PathVariant\PlaceHolderInternalPath; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Responds with internal paths provided by core. + */ +final class EntityPathsEventSubscriber implements EventSubscriberInterface { + + /** + * Subscriber for canonical internal path. + */ + public function canonicalInternalPath(EntityPathsEvent $event): void { + $event->addInternalPath( + $event->getEntity()->isNew() ? PlaceHolderInternalPath::create() : '/' . $event->getEntity()->toUrl(rel: 'canonical')->getInternalPath(), + PathVariant::createDefault(), + ); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + EntityPathsEvent::class => [ + ['canonicalInternalPath'], + ], + ]; + } + +} diff --git a/core/modules/path/src/EventSubscriber/PathVariantEventSubscriber.php b/core/modules/path/src/EventSubscriber/PathVariantEventSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..55ff1ef00d23790d93111697ef7dc8e28cdf3b86 --- /dev/null +++ b/core/modules/path/src/EventSubscriber/PathVariantEventSubscriber.php @@ -0,0 +1,32 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\EventSubscriber; + +use Drupal\path\Event\PathVariantEvent; +use Drupal\path\PathVariant\PathVariant; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Responds with variants provided by core. + */ +final class PathVariantEventSubscriber implements EventSubscriberInterface { + + /** + * The default variant. + */ + public function defaultVariant(PathVariantEvent $event): void { + $event->addVariant(PathVariant::createDefault()); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return [ + PathVariantEvent::class => 'defaultVariant', + ]; + } + +} diff --git a/core/modules/path/src/Hook/PathHooks.php b/core/modules/path/src/Hook/PathHooks.php index 42b79e270c45315d74a371bb2066f4ae52a4e469..b3934780f8954db6583a4801ae2a4cc8943df793 100644 --- a/core/modules/path/src/Hook/PathHooks.php +++ b/core/modules/path/src/Hook/PathHooks.php @@ -2,6 +2,7 @@ namespace Drupal\path\Hook; +use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Entity\ContentEntityInterface; @@ -102,7 +103,13 @@ public function entityBaseFieldInfoAlter(&$fields, EntityTypeInterface $entity_t #[Hook('entity_base_field_info')] public function entityBaseFieldInfo(EntityTypeInterface $entity_type) { if (in_array($entity_type->id(), ['taxonomy_term', 'node', 'media'], TRUE)) { - $fields['path'] = BaseFieldDefinition::create('path')->setLabel(t('URL alias'))->setTranslatable(TRUE)->setDisplayOptions('form', ['type' => 'path', 'weight' => 30])->setDisplayConfigurable('form', TRUE)->setComputed(TRUE); + $fields['path'] = BaseFieldDefinition::create('path') + ->setLabel(t('URL alias')) + ->setTranslatable(TRUE) + ->setCardinality(FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED) + ->setDisplayOptions('form', ['type' => 'path', 'weight' => 30]) + ->setDisplayConfigurable('form', TRUE) + ->setComputed(TRUE); return $fields; } } diff --git a/core/modules/path/src/PathVariant/CorePathVariants.php b/core/modules/path/src/PathVariant/CorePathVariants.php new file mode 100644 index 0000000000000000000000000000000000000000..bb636b10c6689edb9a578f23dda4783ac7c0470e --- /dev/null +++ b/core/modules/path/src/PathVariant/CorePathVariants.php @@ -0,0 +1,22 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\PathVariant; + +use Drupal\path\Enum\PathVariantEnumInterface; + +/** + * Represents static path variants provided by core. + */ +enum CorePathVariants implements PathVariantEnumInterface { + + case Default; + + public function getMachineName(): string { + return match ($this) { + static::Default => PathVariant::DEFAULT, + }; + } + +} diff --git a/core/modules/path/src/PathVariant/PathVariant.php b/core/modules/path/src/PathVariant/PathVariant.php new file mode 100644 index 0000000000000000000000000000000000000000..933bd2f42057ca644caf7a7bf2c82086a8644fc6 --- /dev/null +++ b/core/modules/path/src/PathVariant/PathVariant.php @@ -0,0 +1,84 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\PathVariant; + +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\path\Enum\PathVariantEnumInterface; + +/** + * Represents a path variant for a bundle. + */ +final class PathVariant { + + /** + * Represents the default variant. + */ + public const DEFAULT = 'default'; + + private function __construct( + private PathVariantEnumInterface|string $variant, + private \Stringable|string $label, + ) { + } + + /** + * Represents a path variant for a bundle. + */ + public static function create( + PathVariantEnumInterface|string $variant, + \Stringable|string $label, + ): static { + return new static($variant, $label); + } + + /** + * Create a variant representing the default for an entity. + * + * @internal + * Not for public use. + */ + public static function createDefault(): static { + return new static(CorePathVariants::Default, new TranslatableMarkup('Default')); + } + + /** + * The variant, either a string or enum. + * + * Implementations of static variants should use enums, variants backed by + * user configuration may choose to use string. + * + * @return \Drupal\path\Enum\PathVariantEnumInterface|string + */ + public function getVariant(): PathVariantEnumInterface|string { + return $this->variant; + } + + /** + * Convert the variant to a string. + * + * Used as the value of the PathAlias variant field. + */ + public function getVariantStringed(): string { + return $this->variant instanceof PathVariantEnumInterface ? $this->variant->getMachineName() : $this->variant; + } + + /** + * Get the user-facing label for this variant. + */ + public function getLabel(): \Stringable|string { + return $this->label; + } + + /** + * Converts the variant to a string. + * + * Primarily used as the value of the PathAlias variant field and + * serialization. + */ + public function __toString(): string { + return $this->getVariantStringed(); + } + +} diff --git a/core/modules/path/src/PathVariant/PathVariantRepository.php b/core/modules/path/src/PathVariant/PathVariantRepository.php new file mode 100644 index 0000000000000000000000000000000000000000..a10b1c63a1f45451c37e79717a3b0af8d6f209e1 --- /dev/null +++ b/core/modules/path/src/PathVariant/PathVariantRepository.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\PathVariant; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\path\Event\EntityPathsEvent; +use Drupal\path\Event\PathVariantEvent; +use Symfony\Contracts\EventDispatcher\EventDispatcherInterface; + +/** + * Constructs the path variant repository. + */ +final class PathVariantRepository implements PathVariantRepositoryInterface { + + /** + * Constructs a PathVariantRepository. + */ + public function __construct( + public EventDispatcherInterface $dispatcher, + ) { + } + + /** + * {@inheritdoc} + */ + public function getInternalPaths(EntityInterface $entity): iterable { + $this->dispatcher->dispatch($event = EntityPathsEvent::create($entity)); + return $event->getInternalPaths(); + } + + /** + * {@inheritdoc} + */ + public function getInternalPathByPathVariant(EntityInterface $entity, PathVariant $pathVariant): string|PlaceHolderInternalPath { + foreach ($this->getInternalPaths($entity) as [$internalPath, $variant]) { + if ($variant->getVariant() === $pathVariant->getVariant()) { + return $internalPath; + } + } + + throw new \InvalidArgumentException('Unknown path variant.'); + } + + /** + * {@inheritdoc} + */ + public function getDefaultPathVariant(EntityInterface $entity): PathVariant { + foreach ($this->getInternalPaths($entity) as [1 => $variant]) { + // Return the first. + // @todo this can be improved in a future issue. + return $variant; + } + + throw new \InvalidArgumentException('Entity has no path variants.'); + } + + /** + * {@inheritdoc} + */ + public function getPathVariantsForEntity(EntityInterface $entity): iterable { + $this->dispatcher->dispatch($event = PathVariantEvent::createFromEntity($entity)); + return $event->getVariants(); + } + +} diff --git a/core/modules/path/src/PathVariant/PathVariantRepositoryInterface.php b/core/modules/path/src/PathVariant/PathVariantRepositoryInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..07b71ece4dc21dd907c4d22ff8f963dd3cd72901 --- /dev/null +++ b/core/modules/path/src/PathVariant/PathVariantRepositoryInterface.php @@ -0,0 +1,53 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\PathVariant; + +use Drupal\Core\Entity\EntityInterface; + +/** + * Interface for path variant repository. + * + * @phpstan-import-type InternalPaths from \Drupal\path\Event\EntityPathsEvent + * @phpstan-import-type Variants from \Drupal\path\Event\PathVariantEvent + */ +interface PathVariantRepositoryInterface { + + /** + * Get internal paths for an entity. + * + * When final internal paths cannot be computed, such as before an entity is + * saved for the first time, a placeholder value object is used. + * + * @return InternalPaths + */ + public function getInternalPaths(EntityInterface $entity): iterable; + + /** + * Get an internal path for the path variant of an entity. + * + * @return non-empty-string|\Drupal\path\PathVariant\PlaceHolderInternalPath + * The internal path. + * + * @throws \InvalidArgumentException + * Thrown when the path variant does not exist. + */ + public function getInternalPathByPathVariant(EntityInterface $entity, PathVariant $pathVariant): string|PlaceHolderInternalPath; + + /** + * Get the default path variant. + * + * @throws \InvalidArgumentException + * Thrown when the path variant does not exist. + */ + public function getDefaultPathVariant(EntityInterface $entity): PathVariant; + + /** + * Get the path variants for an entity. + * + * @return Variants + */ + public function getPathVariantsForEntity(EntityInterface $entity): iterable; + +} diff --git a/core/modules/path/src/PathVariant/PlaceHolderInternalPath.php b/core/modules/path/src/PathVariant/PlaceHolderInternalPath.php new file mode 100644 index 0000000000000000000000000000000000000000..362f0a506a47749f971626b322f4d032f3144fbc --- /dev/null +++ b/core/modules/path/src/PathVariant/PlaceHolderInternalPath.php @@ -0,0 +1,26 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\path\PathVariant; + +/** + * Represents an internal path that cannot be determined yet. + * + * Usually since an entity hasn't been saved yet. + */ +final class PlaceHolderInternalPath { + + private function __construct() {} + + /** + * Creates a new internal path placeholder. + * + * @internal + * Not for public use. + */ + public static function create(): static { + return new static(); + } + +} diff --git a/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php b/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php index 08710f943375eb5c852a0539b4a3eb2636095a84..e6c1178ee0298c00c610b86484a6430f008ec78d 100644 --- a/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php +++ b/core/modules/path/src/Plugin/Field/FieldType/PathFieldItemList.php @@ -1,14 +1,21 @@ <?php +declare(strict_types=1); + namespace Drupal\path\Plugin\Field\FieldType; use Drupal\Core\Access\AccessResult; use Drupal\Core\Field\FieldItemList; use Drupal\Core\Session\AccountInterface; use Drupal\Core\TypedData\ComputedItemListTrait; +use Drupal\path\PathVariant\CorePathVariants; +use Drupal\path\PathVariant\PathVariantRepositoryInterface; +use Drupal\path\PathVariant\PlaceHolderInternalPath; +use Drupal\path_alias\AliasRepositoryInterface; +use Drupal\path_alias\PathAliasStorage; /** - * Represents a configurable entity path field. + * Represents a path which may be overridden. */ class PathFieldItemList extends FieldItemList { @@ -18,27 +25,37 @@ class PathFieldItemList extends FieldItemList { * {@inheritdoc} */ protected function computeValue() { - // Default the langcode to the current language if this is a new entity or - // there is no alias for an existent entity. - // @todo Set the langcode to not specified for untranslatable fields - // in https://www.drupal.org/node/2689459. - $value = ['langcode' => $this->getLangcode()]; - $entity = $this->getEntity(); - if (!$entity->isNew()) { - /** @var \Drupal\path_alias\AliasRepositoryInterface $path_alias_repository */ - $path_alias_repository = \Drupal::service('path_alias.repository'); + $delta = 0; + foreach (static::pathVariantRepository()->getInternalPaths($entity) as [$internalPath, $variant]) { + $langCode = $this->getLangcode(); + $pathAlias = $internalPath instanceof PlaceHolderInternalPath + ? NULL + : static::pathAliasRepository()->lookupBySystemPath( + $internalPath, + $langCode, + // The 'full' view mode gets the default/legacy NULL value. + $variant->getVariant() === CorePathVariants::Default ? NULL : (string) $variant, + ); - if ($path_alias = $path_alias_repository->lookupBySystemPath('/' . $entity->toUrl()->getInternalPath(), $this->getLangcode())) { - $value = [ - 'alias' => $path_alias['alias'], - 'pid' => $path_alias['id'], - 'langcode' => $path_alias['langcode'], - ]; - } + $value = [ + // Default the langcode to the current language if this is a new + // entity or there is no alias for an existent entity. + // @todo Set the langcode to not specified for untranslatable fields + // in https://www.drupal.org/node/2689459. + 'langcode' => $langCode, + 'variant' => $variant, + ]; + $this->list[$delta] = $this->createItem( + $delta, + ($pathAlias !== NULL ? [ + 'alias' => $pathAlias['alias'], + 'pid' => $pathAlias['id'], + 'langcode' => $pathAlias['langcode'], + ] : []) + $value, + ); + $delta++; } - - $this->list[0] = $this->createItem(0, $value); } /** @@ -57,12 +74,39 @@ public function defaultAccess($operation = 'view', ?AccountInterface $account = public function delete() { // Delete all aliases associated with this entity in the current language. $entity = $this->getEntity(); - $path_alias_storage = \Drupal::entityTypeManager()->getStorage('path_alias'); - $entities = $path_alias_storage->loadByProperties([ - 'path' => '/' . $entity->toUrl()->getInternalPath(), - 'langcode' => $entity->language()->getId(), - ]); - $path_alias_storage->delete($entities); + /** @var \Drupal\path_alias\PathAliasInterface[] $pathAliases */ + $pathAliases = []; + foreach (static::pathVariantRepository()->getInternalPaths($entity) as [$internalPath]) { + $pathAliases += static::pathAliasStorage()->loadByProperties([ + 'path' => $internalPath, + 'langcode' => $entity->language()->getId(), + ]); + } + static::pathAliasStorage()->delete($pathAliases); + } + + /** + * Path alias storage. + */ + private static function pathAliasStorage(): PathAliasStorage { + /** @var \Drupal\path_alias\PathAliasStorage */ + return \Drupal::entityTypeManager()->getStorage('path_alias'); + } + + /** + * Path alias repository service. + */ + private static function pathAliasRepository(): AliasRepositoryInterface { + /** @var \Drupal\path_alias\AliasRepositoryInterface */ + return \Drupal::service(AliasRepositoryInterface::class); + } + + /** + * Get path variant repository service. + */ + private static function pathVariantRepository(): PathVariantRepositoryInterface { + /** @var \Drupal\path\PathVariant\PathVariantRepositoryInterface */ + return \Drupal::service(PathVariantRepositoryInterface::class); } } diff --git a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php index 6dd9111b225d1902996ccba046fe36b6296962f1..8befa982174c7a3acc933155d500976942269282 100644 --- a/core/modules/path/src/Plugin/Field/FieldType/PathItem.php +++ b/core/modules/path/src/Plugin/Field/FieldType/PathItem.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\path\Plugin\Field\FieldType; use Drupal\Component\Utility\Random; @@ -9,9 +11,17 @@ use Drupal\Core\Field\FieldStorageDefinitionInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\TypedData\DataDefinition; +use Drupal\path\PathVariant\CorePathVariants; +use Drupal\path\PathVariant\PathVariantRepositoryInterface; +use Drupal\path_alias\PathAliasStorage; /** - * Defines the 'path' entity field type. + * Defines the 'path' field type. + * + * @property string|null $alias + * @property int|null $pid + * @property string|null $langcode + * @property \Drupal\path\PathVariant\PathVariant|null $variant */ #[FieldType( id: "path", @@ -34,6 +44,8 @@ public static function propertyDefinitions(FieldStorageDefinitionInterface $fiel ->setLabel(t('Path id')); $properties['langcode'] = DataDefinition::create('string') ->setLabel(t('Language Code')); + $properties['variant'] = DataDefinition::create('any') + ->setLabel(t('Path variant')); return $properties; } @@ -64,43 +76,39 @@ public function preSave() { * {@inheritdoc} */ public function postSave($update) { - $path_alias_storage = \Drupal::entityTypeManager()->getStorage('path_alias'); - $entity = $this->getEntity(); - - // If specified, rely on the langcode property for the language, so that the - // existing language of an alias can be kept. That could for example be - // unspecified even if the field/entity has a specific langcode. - $alias_langcode = ($this->langcode && $this->pid) ? $this->langcode : $this->getLangcode(); - - // If we have an alias, we need to create or update a path alias entity. - if ($this->alias) { - if (!$update || !$this->pid) { - $path_alias = $path_alias_storage->create([ - 'path' => '/' . $entity->toUrl()->getInternalPath(), + // If we have an alias, create or update a path alias entity. + if (is_string($this->alias) && strlen($this->alias) > 0) { + // When this is an entity insert or there is no existing path alias ID: + if (!$update || $this->pid === NULL) { + $variant = $this->variant ?? static::pathVariantRepository()->getDefaultPathVariant($this->getEntity()); + + $path_alias = static::pathAliasStorage()->create([ + 'path' => static::pathVariantRepository()->getInternalPathByPathVariant($this->getEntity(), $variant), 'alias' => $this->alias, - 'langcode' => $alias_langcode, + 'variant' => $variant->getVariant() === CorePathVariants::Default ? NULL : (string) $variant, + // If specified, rely on the langcode property for the language, so that the + // existing language of an alias can be kept. That could for example be + // unspecified even if the field/entity has a specific langcode. + 'langcode' => ($this->langcode && $this->pid) ? $this->langcode : $this->getLangcode(), ]); $path_alias->save(); - $this->pid = $path_alias->id(); + $this->pid = (int) $path_alias->id(); } - elseif ($this->pid) { - $path_alias = $path_alias_storage->load($this->pid); + // When this is an entity update and there is an existing path alias ID: + elseif ($this->pid !== NULL) { + $path_alias = static::pathAliasStorage()->load($this->pid); - if ($this->alias != $path_alias->getAlias()) { - $path_alias->setAlias($this->alias); - $path_alias->save(); + if ($this->alias !== $path_alias->getAlias()) { + $path_alias->setAlias($this->alias)->save(); } } } - elseif ($this->pid && !$this->alias) { + elseif ($this->pid !== NULL) { // Otherwise, delete the old alias if the user erased it. - $path_alias = $path_alias_storage->load($this->pid); - if ($entity->isDefaultRevision()) { - $path_alias_storage->delete([$path_alias]); - } - else { - $path_alias_storage->deleteRevision($path_alias->getRevisionID()); - } + $path_alias = static::pathAliasStorage()->load($this->pid); + $this->getEntity()->isDefaultRevision() + ? static::pathAliasStorage()->delete([$path_alias]) + : static::pathAliasStorage()->deleteRevision($path_alias->getRevisionId()); } } @@ -120,4 +128,20 @@ public static function mainPropertyName() { return 'alias'; } + /** + * Path alias storage. + */ + private static function pathAliasStorage(): PathAliasStorage { + /** @var \Drupal\path_alias\PathAliasStorage */ + return \Drupal::entityTypeManager()->getStorage('path_alias'); + } + + /** + * Get path variant repository service. + */ + private static function pathVariantRepository(): PathVariantRepositoryInterface { + /** @var \Drupal\path\PathVariant\PathVariantRepositoryInterface */ + return \Drupal::service(PathVariantRepositoryInterface::class); + } + } diff --git a/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php b/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php index 837929520278c034117ba4bcc13c228b1a4a7fb4..8e691bbb8a866a210db70735c0f4962a55d2fb0a 100644 --- a/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php +++ b/core/modules/path/src/Plugin/Field/FieldWidget/PathWidget.php @@ -7,6 +7,9 @@ use Drupal\Core\Field\WidgetBase; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\path\PathVariant\PathVariantRepositoryInterface; +use Drupal\path\Plugin\Field\FieldType\PathItem; +use Drupal\path_alias\PathAliasStorage; use Symfony\Component\Validator\ConstraintViolationInterface; /** @@ -25,40 +28,39 @@ class PathWidget extends WidgetBase { public function formElement(FieldItemListInterface $items, $delta, array $element, array &$form, FormStateInterface $form_state) { $entity = $items->getEntity(); - $element += [ - '#element_validate' => [[static::class, 'validateFormElement']], - ]; + $item = $items[$delta]; + assert($item instanceof PathItem); + + $element += ['#element_validate' => [[static::class, 'validateFormElement']]]; + $element['alias'] = [ '#type' => 'textfield', - '#title' => $element['#title'], - '#default_value' => $items[$delta]->alias, + '#title' => $this->fieldDefinition->getLabel(), + '#default_value' => $item->alias, '#required' => $element['#required'], '#maxlength' => 255, '#description' => $this->t('Specify an alternative path by which this data can be accessed. For example, type "/about" when writing an about page.'), ]; - $element['pid'] = [ - '#type' => 'value', - '#value' => $items[$delta]->pid, - ]; + + // Pass through item values as hidden values: + $element['langcode'] = ['#type' => 'value', '#value' => $item->langcode]; + $element['pid'] = ['#type' => 'value', '#value' => $item->pid]; $element['source'] = [ '#type' => 'value', - '#value' => !$entity->isNew() ? '/' . $entity->toUrl()->getInternalPath() : NULL, - ]; - $element['langcode'] = [ - '#type' => 'value', - '#value' => $items[$delta]->langcode, + '#value' => $entity->isNew() ? NULL : static::pathVariantRepository()->getInternalPathByPathVariant($entity, $item->variant), ]; + $element['variant'] = ['#type' => 'value', '#value' => $item->variant]; // If the advanced settings tabs-set is available (normally rendered in the // second column on wide-resolutions), place the field as a details element // in this tab-set. - if (isset($form['advanced'])) { + if (array_key_exists('advanced', $form)) { $element += [ '#type' => 'details', '#title' => $this->t('URL path settings'), - '#open' => !empty($items[$delta]->alias), + '#open' => $item->alias !== NULL, '#group' => 'advanced', - '#access' => $entity->get('path')->access('edit'), + '#access' => $entity->get($items->getName())->access('edit'), '#attributes' => [ 'class' => ['path-form'], ], @@ -66,12 +68,36 @@ public function formElement(FieldItemListInterface $items, $delta, array $elemen 'library' => ['path/drupal.path'], ], ]; - $element['#weight'] = 30; + $element['#weight'] = (float) sprintf('30.%s', $delta); } return $element; } + /** + * {@inheritdoc} + */ + protected function formMultipleElements(FieldItemListInterface $items, array &$form, FormStateInterface $form_state): array { + $elements = []; + + foreach ($items as $delta => $item) { + $titleArgs = [ + '@title' => $this->fieldDefinition->getLabel(), + '@variant' => $item->variant?->getLabel() ?? $this->t('Unknown'), + ]; + + $element = [ + '#title' => $items->count() === 1 + ? $this->t('@title', $titleArgs, ['context' => 'entity path no variants']) + // Display variant label when there is at least one available. + : $this->t('@title (@variant)', $titleArgs, ['context' => 'entity path variants']), + ]; + $elements[$delta] = $this->formSingleElement($items, $delta, $element, $form, $form_state); + } + + return $elements; + } + /** * Form element validation handler for URL alias form element. * @@ -87,14 +113,13 @@ public static function validateFormElement(array &$element, FormStateInterface $ $form_state->setValueForElement($element['alias'], $alias); /** @var \Drupal\path_alias\PathAliasInterface $path_alias */ - $path_alias = \Drupal::entityTypeManager()->getStorage('path_alias')->create([ + $path_alias = static::pathAliasStorage()->create([ 'path' => $element['source']['#value'], 'alias' => $alias, 'langcode' => $element['langcode']['#value'], ]); - $violations = $path_alias->validate(); - foreach ($violations as $violation) { + foreach ($path_alias->validate() as $violation) { // Newly created entities do not have a system path yet, so we need to // disregard some violations. if (!$path_alias->getPath() && $violation->getPropertyPath() === 'path') { @@ -112,4 +137,20 @@ public function errorElement(array $element, ConstraintViolationInterface $viola return $element['alias']; } + /** + * Get path variant repository service. + */ + private static function pathVariantRepository(): PathVariantRepositoryInterface { + /** @var \Drupal\path\PathVariant\PathVariantRepositoryInterface */ + return \Drupal::service(PathVariantRepositoryInterface::class); + } + + /** + * Path alias storage. + */ + private static function pathAliasStorage(): PathAliasStorage { + /** @var \Drupal\path_alias\PathAliasStorage */ + return \Drupal::entityTypeManager()->getStorage('path_alias'); + } + } diff --git a/core/modules/path_alias/path_alias.install b/core/modules/path_alias/path_alias.install new file mode 100644 index 0000000000000000000000000000000000000000..47e9615965b5941fbb24af5517f57f5230dabc1c --- /dev/null +++ b/core/modules/path_alias/path_alias.install @@ -0,0 +1,38 @@ +<?php + +/** + * @file + * Update functions for the path module. + */ + +use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\Core\StringTranslation\TranslatableMarkup; + +/** + * Update the path_alias_revision indices. + */ +function path_alias_update_11001(array &$sandbox): TranslatableMarkup { + /** @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $update_manager */ + $update_manager = \Drupal::service('entity.definition_update_manager'); + $entity_type = $update_manager->getEntityType('path_alias'); + $update_manager->updateEntityType($entity_type); + + return new TranslatableMarkup('Updated {path_alias_revision} indices.'); +} + +/** + * Installs the `variant` field on `path_alias` entity type. + */ +function path_alias_update_11002(array &$sandbox): TranslatableMarkup { + $field = BaseFieldDefinition::create('string') + ->setLabel(new TranslatableMarkup('Variant')) + ->setDescription(new TranslatableMarkup('An alternative alias used with this path.')) + ->setRequired(FALSE) + ->setDefaultValue(NULL) + ->setRevisionable(TRUE); + + $entityDefinitionUpdateManager = \Drupal::entityDefinitionUpdateManager(); + $entityDefinitionUpdateManager->installFieldStorageDefinition('variant', 'path_alias', 'path', $field); + + return new TranslatableMarkup('Installed the `variant` field on `path_alias` entity type.'); +} diff --git a/core/modules/path_alias/path_alias.post_update.php b/core/modules/path_alias/path_alias.post_update.php index 91399093cb2159d8c88ddbef7610a1bdd1ea462d..4f29b91b8dac7052c76c4ca45f14ffa44f9b4c4e 100644 --- a/core/modules/path_alias/path_alias.post_update.php +++ b/core/modules/path_alias/path_alias.post_update.php @@ -13,13 +13,3 @@ function path_alias_removed_post_updates(): array { 'path_alias_post_update_drop_path_alias_status_index' => '11.0.0', ]; } - -/** - * Update the path_alias_revision indices. - */ -function path_alias_post_update_update_path_alias_revision_indexes(): void { - /** @var \Drupal\Core\Entity\EntityDefinitionUpdateManagerInterface $update_manager */ - $update_manager = \Drupal::service('entity.definition_update_manager'); - $entity_type = $update_manager->getEntityType('path_alias'); - $update_manager->updateEntityType($entity_type); -} diff --git a/core/modules/path_alias/src/AliasRepository.php b/core/modules/path_alias/src/AliasRepository.php index 21eb3daef0d65bc2a85a14fd9285cea81c827c29..19688b831bfec999f832489610f06266421a379e 100644 --- a/core/modules/path_alias/src/AliasRepository.php +++ b/core/modules/path_alias/src/AliasRepository.php @@ -65,7 +65,7 @@ public function preloadPathAlias($preloaded, $langcode) { /** * {@inheritdoc} */ - public function lookupBySystemPath($path, $langcode) { + public function lookupBySystemPath(string $path, string $langcode, ?string $variant = NULL) { // See the queries above. Use LIKE for case-insensitive matching. $select = $this->getBaseQuery() ->fields('base_table', ['id', 'path', 'alias', 'langcode']) @@ -75,7 +75,17 @@ public function lookupBySystemPath($path, $langcode) { $select->orderBy('base_table.id', 'DESC'); - return $select->execute()->fetchAssoc() ?: NULL; + $variant === NULL + ? $select->condition('base_table.variant', operator: 'IS NULL') + : $select->condition('base_table.variant', value: $variant); + + $result = $select->execute()->fetchAssoc(); + if ($result === FALSE) { + return NULL; + } + + $result['id'] = (int) $result['id']; + return $result; } /** diff --git a/core/modules/path_alias/src/AliasRepositoryInterface.php b/core/modules/path_alias/src/AliasRepositoryInterface.php index 432ec1d6ebe877095ff342bdf9044646e6285550..b45e577b1f33e921b1c730c29b320cf50ae6767f 100644 --- a/core/modules/path_alias/src/AliasRepositoryInterface.php +++ b/core/modules/path_alias/src/AliasRepositoryInterface.php @@ -42,12 +42,12 @@ public function preloadPathAlias($preloaded, $langcode); * @param string $langcode * Language code to search the path with. If there's no path defined for * that language it will search paths without language. + * @param string|null $variant + * The path variant to get. * - * @return array|null - * An array containing the 'id', 'path', 'alias' and 'langcode' properties - * of a path alias, or NULL if none was found. + * @phpstan-return array{id: positive-int, alias: string, alias: string, langcode: string}|null */ - public function lookupBySystemPath($path, $langcode); + public function lookupBySystemPath(string $path, string $langcode, ?string $variant = NULL); /** * Searches a path alias for a given alias. diff --git a/core/modules/path_alias/src/Entity/PathAlias.php b/core/modules/path_alias/src/Entity/PathAlias.php index 6ace9f070708e494a0c468e0aab598da586f59b6..730fbfb5400b8e248403e195a6f8f4dd24f8d776 100644 --- a/core/modules/path_alias/src/Entity/PathAlias.php +++ b/core/modules/path_alias/src/Entity/PathAlias.php @@ -81,6 +81,13 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ], ]); + $fields['variant'] = BaseFieldDefinition::create('string') + ->setLabel(new TranslatableMarkup('Variant')) + ->setDescription(new TranslatableMarkup('An alternative alias used with this path.')) + ->setRequired(FALSE) + ->setDefaultValue(NULL) + ->setRevisionable(TRUE); + $fields['langcode']->setDefaultValue(LanguageInterface::LANGCODE_NOT_SPECIFIED); // Add the published field. diff --git a/core/modules/path_alias/tests/src/Functional/Rest/PathAliasResourceTestBase.php b/core/modules/path_alias/tests/src/Functional/Rest/PathAliasResourceTestBase.php index 898be2250f7fbe8f3bb8209c0df351f1fbb9b5af..6c28ab9e73b65ab0fbac4690097e99fbb9b6557d 100644 --- a/core/modules/path_alias/tests/src/Functional/Rest/PathAliasResourceTestBase.php +++ b/core/modules/path_alias/tests/src/Functional/Rest/PathAliasResourceTestBase.php @@ -97,6 +97,7 @@ protected function getExpectedNormalizedEntity() { 'value' => $this->entity->uuid(), ], ], + 'variant' => [], ]; } diff --git a/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php b/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php index be4aab797bfd23d74bffc12d4ad950cb6b60348e..54cefcca5cf2793772b3f603b08961fe6ddce2f1 100644 --- a/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php +++ b/core/modules/taxonomy/tests/src/Functional/Rest/TermResourceTestBase.php @@ -217,6 +217,7 @@ protected function getExpectedNormalizedEntity() { 'alias' => '/llama', 'pid' => 1, 'langcode' => 'en', + 'variant' => 'default', ], ], 'status' => [