diff --git a/core/lib/Drupal/Core/Entity/Controller/EntityRevisionViewController.php b/core/lib/Drupal/Core/Entity/Controller/EntityRevisionViewController.php new file mode 100644 index 0000000000000000000000000000000000000000..25b2c3761813a03c62cbb0c60a61f5f7655e2e5e --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Controller/EntityRevisionViewController.php @@ -0,0 +1,102 @@ +<?php + +namespace Drupal\Core\Entity\Controller; + +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityRepositoryInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\StringTranslation\TranslationInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Defines a controller to view an entity revision. + */ +class EntityRevisionViewController implements ContainerInjectionInterface { + + use StringTranslationTrait; + + /** + * Creates a new EntityRevisionViewController. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\Core\Entity\EntityRepositoryInterface $entityRepository + * The entity repository. + * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter + * The date formatter. + * @param \Drupal\Core\StringTranslation\TranslationInterface $translation + * The string translation manager. + */ + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected EntityRepositoryInterface $entityRepository, + protected DateFormatterInterface $dateFormatter, + TranslationInterface $translation, + ) { + $this->setStringTranslation($translation); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('entity.repository'), + $container->get('date.formatter'), + $container->get('string_translation'), + ); + } + + /** + * Provides a page to render a single entity revision. + * + * @param \Drupal\Core\Entity\RevisionableInterface $_entity_revision + * The Entity to be rendered. Note this variable is named $_entity_revision + * rather than $entity to prevent collisions with other named placeholders + * in the route. + * @param string $view_mode + * (optional) The view mode that should be used to display the entity. + * Defaults to 'full'. + * + * @return array + * A render array. + */ + public function __invoke(RevisionableInterface $_entity_revision, string $view_mode = 'full'): array { + $entityTypeId = $_entity_revision->getEntityTypeId(); + + $page = $this->entityTypeManager + ->getViewBuilder($entityTypeId) + ->view($_entity_revision, $view_mode); + + $page['#entity_type'] = $entityTypeId; + $page['#' . $entityTypeId] = $_entity_revision; + return $page; + } + + /** + * Provides a title callback for a revision of an entity. + * + * @param \Drupal\Core\Entity\RevisionableInterface $_entity_revision + * The revisionable entity, passed in directly from request attributes. + * + * @return \Drupal\Core\StringTranslation\TranslatableMarkup + * The title for the entity revision view page. + */ + public function title(RevisionableInterface $_entity_revision): TranslatableMarkup { + $revision = $this->entityRepository->getTranslationFromContext($_entity_revision); + $titleArgs = ['%title' => $revision->label()]; + if (!$revision instanceof RevisionLogInterface) { + return $this->t('Revision of %title', $titleArgs); + } + + $titleArgs['%date'] = $this->dateFormatter->format($revision->getRevisionCreationTime()); + return $this->t('Revision of %title from %date', $titleArgs); + } + +} diff --git a/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php b/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php index 979721832e0ae9db8afce6158301e584470332ef..cf660ab0cbc9981fb7e36aa3c9739696f1a224ea 100644 --- a/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php +++ b/core/lib/Drupal/Core/Entity/Controller/EntityViewController.php @@ -184,8 +184,14 @@ public static function trustedCallbacks() { * * @return array * A render array. + * + * @deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use + * \Drupal\Core\Entity\Controller\EntityRevisionViewController instead. + * + * @see https://www.drupal.org/node/3314346 */ public function viewRevision(EntityInterface $_entity_revision, $view_mode = 'full') { + @trigger_error(__METHOD__ . ' is deprecated in drupal:10.1.0 and is removed from drupal:11.0.0. Use \Drupal\Core\Entity\Controller\EntityRevisionViewController instead. See https://www.drupal.org/node/3314346.', E_USER_DEPRECATED); return $this->view($_entity_revision, $view_mode); } diff --git a/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php b/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php new file mode 100644 index 0000000000000000000000000000000000000000..def6fe1cde7bb9213930e0feb72a81b574be2387 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php @@ -0,0 +1,311 @@ +<?php + +namespace Drupal\Core\Entity\Controller; + +use Drupal\Component\Utility\Xss; +use Drupal\Core\Cache\CacheableMetadata; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\RevisionableStorageInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Render\RendererInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Entity\RevisionLogInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a controller showing revision history for an entity. + * + * This controller is agnostic to any entity type by using + * \Drupal\Core\Entity\RevisionLogInterface. + */ +class VersionHistoryController extends ControllerBase { + + /** + * Constructs a new VersionHistoryController. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager + * The language manager. + * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter + * The date formatter service. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. + */ + public function __construct( + EntityTypeManagerInterface $entityTypeManager, + LanguageManagerInterface $languageManager, + protected DateFormatterInterface $dateFormatter, + protected RendererInterface $renderer, + ) { + $this->entityTypeManager = $entityTypeManager; + $this->languageManager = $languageManager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('language_manager'), + $container->get('date.formatter'), + $container->get('renderer'), + ); + } + + /** + * Generates an overview table of revisions for an entity. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch + * The route match. + * + * @return array + * A render array. + */ + public function __invoke(RouteMatchInterface $routeMatch): array { + $entityTypeId = $routeMatch->getRouteObject()->getOption('entity_type_id'); + $entity = $routeMatch->getParameter($entityTypeId); + return $this->revisionOverview($entity); + } + + /** + * Builds a link to revert an entity revision. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The entity to build a revert revision link for. + * + * @return array|null + * A link to revert an entity revision, or NULL if the entity type does not + * have an a route to revert an entity revision. + */ + protected function buildRevertRevisionLink(RevisionableInterface $revision): ?array { + if (!$revision->hasLinkTemplate('revision-revert-form')) { + return NULL; + } + + $url = $revision->toUrl('revision-revert-form'); + // @todo Merge in cacheability after + // https://www.drupal.org/project/drupal/issues/2473873. + if (!$url->access()) { + return NULL; + } + + return [ + 'title' => $this->t('Revert'), + 'url' => $url, + ]; + } + + /** + * Builds a link to delete an entity revision. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The entity to build a delete revision link for. + * + * @return array|null + * A link render array. + */ + protected function buildDeleteRevisionLink(RevisionableInterface $revision): ?array { + if (!$revision->hasLinkTemplate('revision-delete-form')) { + return NULL; + } + + $url = $revision->toUrl('revision-delete-form'); + // @todo Merge in cacheability after + // https://www.drupal.org/project/drupal/issues/2473873. + if (!$url->access()) { + return NULL; + } + + return [ + 'title' => $this->t('Delete'), + 'url' => $url, + ]; + } + + /** + * Get a description of the revision. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The entity revision. + * + * @return array + * A render array describing the revision. + */ + protected function getRevisionDescription(RevisionableInterface $revision): array { + $context = []; + if ($revision instanceof RevisionLogInterface) { + // Use revision link to link to revisions that are not active. + ['type' => $dateFormatType, 'format' => $dateFormatFormat] = $this->getRevisionDescriptionDateFormat($revision); + $linkText = $this->dateFormatter->format($revision->getRevisionCreationTime(), $dateFormatType, $dateFormatFormat); + + // @todo Simplify this when https://www.drupal.org/node/2334319 lands. + $username = [ + '#theme' => 'username', + '#account' => $revision->getRevisionUser(), + ]; + $context['username'] = $this->renderer->render($username); + } + else { + $linkText = $revision->access('view label') ? $revision->label() : $this->t('- Restricted access -'); + } + + $revisionViewLink = $revision->toLink($linkText, 'revision'); + $context['revision'] = $revisionViewLink->getUrl()->access() + ? $revisionViewLink->toString() + : (string) $revisionViewLink->getText(); + $context['message'] = $revision instanceof RevisionLogInterface ? [ + '#markup' => $revision->getRevisionLogMessage(), + '#allowed_tags' => Xss::getHtmlTagList(), + ] : ''; + + return [ + 'data' => [ + '#type' => 'inline_template', + '#template' => isset($context['username']) + ? '{% trans %} {{ revision }} by {{ username }}{% endtrans %}{% if message %}<p class="revision-log">{{ message }}</p>{% endif %}' + : '{% trans %} {{ revision }} {% endtrans %}{% if message %}<p class="revision-log">{{ message }}</p>{% endif %}', + '#context' => $context, + ], + ]; + } + + /** + * Date format to use for revision description dates. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The revision in context. + * + * @return array + * An array with keys 'type' and optionally 'format' suitable for passing + * to date formatter service. + */ + protected function getRevisionDescriptionDateFormat(RevisionableInterface $revision): array { + return [ + 'type' => 'short', + 'format' => '', + ]; + } + + /** + * Generates revisions of an entity relevant to the current language. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * The entity. + * + * @return \Generator|\Drupal\Core\Entity\RevisionableInterface + * Generates revisions. + */ + protected function loadRevisions(RevisionableInterface $entity) { + $entityType = $entity->getEntityType(); + $translatable = $entityType->isTranslatable(); + $entityStorage = $this->entityTypeManager->getStorage($entity->getEntityTypeId()); + assert($entityStorage instanceof RevisionableStorageInterface); + + $result = $entityStorage->getQuery() + ->accessCheck(FALSE) + ->allRevisions() + ->condition($entityType->getKey('id'), $entity->id()) + ->sort($entityType->getKey('revision'), 'DESC') + ->execute(); + + $currentLangcode = $this->languageManager + ->getCurrentLanguage(LanguageInterface::TYPE_CONTENT) + ->getId(); + foreach ($entityStorage->loadMultipleRevisions(array_keys($result)) as $revision) { + // Only show revisions that are affected by the language that is being + // displayed. + if (!$translatable || ($revision->hasTranslation($currentLangcode) && $revision->getTranslation($currentLangcode)->isRevisionTranslationAffected())) { + yield $revision; + } + } + } + + /** + * Generates an overview table of revisions of an entity. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * A revisionable entity. + * + * @return array + * A render array. + */ + protected function revisionOverview(RevisionableInterface $entity): array { + $build['entity_revisions_table'] = [ + '#theme' => 'table', + '#header' => [ + 'revision' => ['data' => $this->t('Revision')], + 'operations' => ['data' => $this->t('Operations')], + ], + ]; + + foreach ($this->loadRevisions($entity) as $revision) { + $build['entity_revisions_table']['#rows'][$revision->getRevisionId()] = $this->buildRow($revision); + } + + (new CacheableMetadata()) + // Only dealing with this entity and no external dependencies. + ->addCacheableDependency($entity) + ->addCacheContexts(['languages:language_content']) + ->applyTo($build); + + return $build; + } + + /** + * Builds a table row for a revision. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * An entity revision. + * + * @return array + * A table row. + */ + protected function buildRow(RevisionableInterface $revision): array { + $row = []; + $rowAttributes = []; + + $row['revision']['data'] = $this->getRevisionDescription($revision); + $row['operations']['data'] = []; + + // Revision status. + if ($revision->isDefaultRevision()) { + $rowAttributes['class'][] = 'revision-current'; + $row['operations']['data']['status']['#markup'] = $this->t('<em>Current revision</em>'); + } + + // Operation links. + $links = $this->getOperationLinks($revision); + if (count($links) > 0) { + $row['operations']['data']['operations'] = [ + '#type' => 'operations', + '#links' => $links, + ]; + } + + return ['data' => $row] + $rowAttributes; + } + + /** + * Get operations for an entity revision. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The entity to build revision links for. + * + * @return array + * An array of operation links. + */ + protected function getOperationLinks(RevisionableInterface $revision): array { + // Removes links which are inaccessible or not rendered. + return array_filter([ + $this->buildRevertRevisionLink($revision), + $this->buildDeleteRevisionLink($revision), + ]); + } + +} diff --git a/core/lib/Drupal/Core/Entity/Form/RevisionDeleteForm.php b/core/lib/Drupal/Core/Entity/Form/RevisionDeleteForm.php new file mode 100644 index 0000000000000000000000000000000000000000..7c3fdd177a49558ea527a07824051a2ea2b576ea --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Form/RevisionDeleteForm.php @@ -0,0 +1,283 @@ +<?php + +namespace Drupal\Core\Entity\Form; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\Entity\EntityFormInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Form\ConfirmFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form for deleting an entity revision. + * + * @internal + */ +class RevisionDeleteForm extends ConfirmFormBase implements EntityFormInterface { + + /** + * The entity operation. + * + * @var string + */ + protected string $operation; + + /** + * The entity revision. + * + * @var \Drupal\Core\Entity\RevisionableInterface + */ + protected RevisionableInterface $revision; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected ModuleHandlerInterface $moduleHandler; + + /** + * Creates a new RevisionDeleteForm instance. + * + * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter + * The date formatter. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * Entity type manager. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundleInformation + * The bundle information. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Drupal\Core\Session\AccountInterface $currentUser + * The current user. + */ + public function __construct( + protected DateFormatterInterface $dateFormatter, + protected EntityTypeManagerInterface $entityTypeManager, + protected EntityTypeBundleInfoInterface $bundleInformation, + MessengerInterface $messenger, + protected TimeInterface $time, + protected AccountInterface $currentUser, + ) { + $this->messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('date.formatter'), + $container->get('entity_type.manager'), + $container->get('entity_type.bundle.info'), + $container->get('messenger'), + $container->get('datetime.time'), + $container->get('current_user') + ); + } + + /** + * {@inheritdoc} + */ + public function getBaseFormId() { + return $this->revision->getEntityTypeId() . '_revision_delete'; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return $this->revision->getEntityTypeId() . '_revision_delete'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return ($this->getEntity() instanceof RevisionLogInterface) + ? $this->t('Are you sure you want to delete the revision from %revision-date?', [ + '%revision-date' => $this->dateFormatter->format($this->getEntity()->getRevisionCreationTime()), + ]) + : $this->t('Are you sure you want to delete the revision?'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return $this->getEntity()->getEntityType()->hasLinkTemplate('version-history') && $this->getEntity()->toUrl('version-history')->access($this->currentUser) + ? $this->getEntity()->toUrl('version-history') + : $this->getEntity()->toUrl(); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Delete'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return ''; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $entityTypeId = $this->revision->getEntityTypeId(); + $entityStorage = $this->entityTypeManager->getStorage($entityTypeId); + $entityStorage->deleteRevision($this->revision->getRevisionId()); + + $bundleLabel = $this->getBundleLabel($this->revision); + $messengerArgs = [ + '@type' => $bundleLabel ?? $this->revision->getEntityType()->getLabel(), + '%title' => $this->revision->label(), + ]; + if ($this->revision instanceof RevisionLogInterface) { + $messengerArgs['%revision-date'] = $this->dateFormatter->format($this->revision->getRevisionCreationTime()); + $this->messenger->addStatus($this->t('Revision from %revision-date of @type %title has been deleted.', $messengerArgs)); + } + else { + $this->messenger->addStatus($this->t('Revision of @type %title has been deleted.', $messengerArgs)); + } + + $this->logger($this->revision->getEntityType()->getProvider())->notice('@type: deleted %title revision %revision.', [ + '@type' => $this->revision->bundle(), + '%title' => $this->revision->label(), + '%revision' => $this->revision->getRevisionId(), + ]); + + // When there is one remaining revision or more, redirect to the version + // history page. + if ($this->revision->hasLinkTemplate('version-history')) { + $query = $this->entityTypeManager->getStorage($entityTypeId)->getQuery(); + $remainingRevisions = $query + ->accessCheck(FALSE) + ->allRevisions() + ->condition($this->revision->getEntityType()->getKey('id'), $this->revision->id()) + ->count() + ->execute(); + $versionHistoryUrl = $this->revision->toUrl('version-history'); + if ($remainingRevisions && $versionHistoryUrl->access($this->currentUser())) { + $form_state->setRedirectUrl($versionHistoryUrl); + } + } + + if (!$form_state->getRedirect()) { + $canonicalUrl = $this->revision->toUrl(); + if ($canonicalUrl->access($this->currentUser())) { + $form_state->setRedirectUrl($canonicalUrl); + } + } + } + + /** + * Returns the bundle label of an entity. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * The entity. + * + * @return string|null + * The bundle label. + */ + protected function getBundleLabel(RevisionableInterface $entity): ?string { + $bundleInfo = $this->bundleInformation->getBundleInfo($entity->getEntityTypeId()); + return isset($bundleInfo[$entity->bundle()]['label']) ? (string) $bundleInfo[$entity->bundle()]['label'] : NULL; + } + + /** + * {@inheritdoc} + */ + public function setOperation($operation) { + $this->operation = $operation; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getOperation() { + return $this->operation; + } + + /** + * {@inheritdoc} + */ + public function getEntity() { + return $this->revision; + } + + /** + * {@inheritdoc} + */ + public function setEntity(EntityInterface $entity) { + assert($entity instanceof RevisionableInterface); + $this->revision = $entity; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entity_type_id) { + return $route_match->getParameter($entity_type_id . '_revision'); + } + + /** + * {@inheritdoc} + */ + public function buildEntity(array $form, FormStateInterface $form_state) { + return $this->revision; + } + + /** + * {@inheritdoc} + * + * The save() method is not used in RevisionDeleteForm. This + * overrides the default implementation that saves the entity. + * + * Confirmation forms should override submitForm() instead for their logic. + */ + public function save(array $form, FormStateInterface $form_state) { + throw new \LogicException('The save() method is not used in RevisionDeleteForm'); + } + + /** + * {@inheritdoc} + */ + public function setModuleHandler(ModuleHandlerInterface $module_handler) { + $this->moduleHandler = $module_handler; + return $this; + } + + /** + * {@inheritdoc} + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + return $this; + } + + /** + * {@inheritdoc} + */ + protected function currentUser() { + return $this->currentUser; + } + +} diff --git a/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php b/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php new file mode 100644 index 0000000000000000000000000000000000000000..23f4b4d8361463cefab9784b180aba0d04bcedf1 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php @@ -0,0 +1,327 @@ +<?php + +namespace Drupal\Core\Entity\Form; + +use Drupal\Component\Datetime\TimeInterface; +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\Entity\EntityChangedInterface; +use Drupal\Core\Entity\EntityFormInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Entity\RevisionableInterface; +use Drupal\Core\Entity\RevisionableStorageInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Form\ConfirmFormBase; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\Core\Messenger\MessengerInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a form for reverting an entity revision. + * + * @internal + */ +class RevisionRevertForm extends ConfirmFormBase implements EntityFormInterface { + + /** + * The entity operation. + * + * @var string + */ + protected string $operation; + + /** + * The entity revision. + * + * @var \Drupal\Core\Entity\RevisionableInterface + */ + protected RevisionableInterface $revision; + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected ModuleHandlerInterface $moduleHandler; + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * Creates a new RevisionRevertForm instance. + * + * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter + * The date formatter. + * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundleInformation + * The bundle information. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger service. + * @param \Drupal\Component\Datetime\TimeInterface $time + * The time service. + * @param \Drupal\Core\Session\AccountInterface $currentUser + * The current user. + */ + public function __construct( + protected DateFormatterInterface $dateFormatter, + protected EntityTypeBundleInfoInterface $bundleInformation, + MessengerInterface $messenger, + protected TimeInterface $time, + protected AccountInterface $currentUser, + ) { + $this->messenger = $messenger; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('date.formatter'), + $container->get('entity_type.bundle.info'), + $container->get('messenger'), + $container->get('datetime.time'), + $container->get('current_user'), + ); + } + + /** + * {@inheritdoc} + */ + public function getBaseFormId() { + return $this->revision->getEntityTypeId() . '_revision_revert'; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return $this->revision->getEntityTypeId() . '_revision_revert'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + return ($this->getEntity() instanceof RevisionLogInterface) + ? $this->t('Are you sure you want to revert to the revision from %revision-date?', [ + '%revision-date' => $this->dateFormatter->format($this->getEntity()->getRevisionCreationTime()), + ]) + : $this->t('Are you sure you want to revert the revision?'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + return $this->getEntity()->getEntityType()->hasLinkTemplate('version-history') && $this->getEntity()->toUrl('version-history')->access($this->currentUser) + ? $this->getEntity()->toUrl('version-history') + : $this->getEntity()->toUrl(); + } + + /** + * {@inheritdoc} + */ + public function getConfirmText() { + return $this->t('Revert'); + } + + /** + * {@inheritdoc} + */ + public function getDescription() { + return ''; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $form = parent::buildForm($form, $form_state); + $form['actions']['submit']['#submit'] = [ + '::submitForm', + '::save', + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $revisionId = $this->revision->getRevisionId(); + $revisionLabel = $this->revision->label(); + $bundleLabel = $this->getBundleLabel($this->revision); + if ($this->revision instanceof RevisionLogInterface) { + $originalRevisionTimestamp = $this->revision->getRevisionCreationTime(); + } + + $this->revision = $this->prepareRevision($this->revision, $form_state); + + if ($this->revision instanceof RevisionLogInterface) { + $date = $this->dateFormatter->format($originalRevisionTimestamp); + $this->messenger->addMessage($this->t('@type %title has been reverted to the revision from %revision-date.', [ + '@type' => $bundleLabel, + '%title' => $revisionLabel, + '%revision-date' => $date, + ])); + } + else { + $this->messenger->addMessage($this->t('@type %title has been reverted.', [ + '@type' => $bundleLabel, + '%title' => $revisionLabel, + ])); + } + + $this->logger($this->revision->getEntityType()->getProvider())->notice('@type: reverted %title revision %revision.', [ + '@type' => $this->revision->bundle(), + '%title' => $revisionLabel, + '%revision' => $revisionId, + ]); + + $versionHistoryUrl = $this->revision->toUrl('version-history'); + if ($versionHistoryUrl->access($this->currentUser())) { + $form_state->setRedirectUrl($versionHistoryUrl); + } + + if (!$form_state->getRedirect()) { + $canonicalUrl = $this->revision->toUrl(); + if ($canonicalUrl->access($this->currentUser())) { + $form_state->setRedirectUrl($canonicalUrl); + } + } + } + + /** + * Prepares a revision to be reverted. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The revision to be reverted. + * @param \Drupal\Core\Form\FormStateInterface $formState + * The current state of the form. + * + * @return \Drupal\Core\Entity\RevisionableInterface + * The new revision, the same type as passed to $revision. + */ + protected function prepareRevision(RevisionableInterface $revision, FormStateInterface $formState): RevisionableInterface { + $storage = $this->entityTypeManager->getStorage($revision->getEntityTypeId()); + if (!$storage instanceof RevisionableStorageInterface) { + throw new \LogicException('Revisionable entities are expected to implement RevisionableStorageInterface'); + } + + $revision = $storage->createRevision($revision); + + $time = $this->time->getRequestTime(); + if ($revision instanceof EntityChangedInterface) { + $revision->setChangedTime($time); + } + + if ($revision instanceof RevisionLogInterface) { + $originalRevisionTimestamp = $revision->getRevisionCreationTime(); + $date = $this->dateFormatter->format($originalRevisionTimestamp); + $revision + ->setRevisionLogMessage($this->t('Copy of the revision from %date.', ['%date' => $date])) + ->setRevisionCreationTime($time) + ->setRevisionUserId($this->currentUser()->id()); + } + + return $revision; + } + + /** + * Returns the bundle label of an entity. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * The entity. + * + * @return string|null + * The bundle label. + */ + protected function getBundleLabel(RevisionableInterface $entity): ?string { + $bundleInfo = $this->bundleInformation->getBundleInfo($entity->getEntityTypeId()); + return isset($bundleInfo[$entity->bundle()]['label']) ? (string) $bundleInfo[$entity->bundle()]['label'] : NULL; + } + + /** + * {@inheritdoc} + */ + public function setOperation($operation) { + $this->operation = $operation; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getOperation() { + return $this->operation; + } + + /** + * {@inheritdoc} + */ + public function getEntity() { + return $this->revision; + } + + /** + * {@inheritdoc} + */ + public function setEntity(EntityInterface $entity) { + assert($entity instanceof RevisionableInterface); + $this->revision = $entity; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getEntityFromRouteMatch(RouteMatchInterface $route_match, $entity_type_id) { + return $route_match->getParameter($entity_type_id . '_revision'); + } + + /** + * {@inheritdoc} + */ + public function buildEntity(array $form, FormStateInterface $form_state) { + return $this->revision; + } + + /** + * {@inheritdoc} + */ + public function save(array $form, FormStateInterface $form_state) { + return $this->revision->save(); + } + + /** + * {@inheritdoc} + */ + public function setModuleHandler(ModuleHandlerInterface $module_handler) { + $this->moduleHandler = $module_handler; + return $this; + } + + /** + * {@inheritdoc} + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager) { + $this->entityTypeManager = $entity_type_manager; + return $this; + } + + /** + * {@inheritdoc} + */ + protected function currentUser() { + return $this->currentUser; + } + +} diff --git a/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php b/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php new file mode 100644 index 0000000000000000000000000000000000000000..4d0fd5259f7f35c1c3d7db0bca7acaf876e9d758 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php @@ -0,0 +1,54 @@ +<?php + +namespace Drupal\Core\Entity\Plugin\Derivative; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides version history local tasks for revisionable entities. + */ +class VersionHistoryLocalTasks extends DeriverBase implements ContainerDeriverInterface { + + /** + * Creates a new VersionHistoryLocalTasks instance. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + */ + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + ) { + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives = []; + foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) { + if (!$entity_type->hasLinkTemplate('version-history')) { + continue; + } + + $this->derivatives["$entity_type_id.version_history"] = [ + 'route_name' => "entity.$entity_type_id.version_history", + 'base_route' => "entity.$entity_type_id.canonical", + ] + $base_plugin_definition; + } + + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php b/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php new file mode 100644 index 0000000000000000000000000000000000000000..f2f1cd90896f98a2764d28fa528f190d281a6964 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php @@ -0,0 +1,174 @@ +<?php + +namespace Drupal\Core\Entity\Routing; + +use Drupal\Core\Entity\Controller\EntityRevisionViewController; +use Drupal\Core\Entity\Controller\VersionHistoryController; +use Drupal\Core\Entity\EntityTypeInterface; +use Symfony\Component\Routing\Route; +use Symfony\Component\Routing\RouteCollection; + +/** + * Provides entity revision routes. + */ +class RevisionHtmlRouteProvider implements EntityRouteProviderInterface { + + /** + * {@inheritdoc} + */ + public function getRoutes(EntityTypeInterface $entity_type) { + $collection = new RouteCollection(); + $entityTypeId = $entity_type->id(); + + if ($version_history_route = $this->getVersionHistoryRoute($entity_type)) { + $collection->add("entity.$entityTypeId.version_history", $version_history_route); + } + + if ($revision_view_route = $this->getRevisionViewRoute($entity_type)) { + $collection->add("entity.$entityTypeId.revision", $revision_view_route); + } + + if ($revision_revert_route = $this->getRevisionRevertRoute($entity_type)) { + $collection->add("entity.$entityTypeId.revision_revert_form", $revision_revert_route); + } + + if ($revision_delete_route = $this->getRevisionDeleteRoute($entity_type)) { + $collection->add("entity.$entityTypeId.revision_delete_form", $revision_delete_route); + } + + return $collection; + } + + /** + * Gets the entity revision history route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entityType + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The entity revision revert route, or NULL if the entity type does not + * support viewing version history. + */ + protected function getVersionHistoryRoute(EntityTypeInterface $entityType): ?Route { + if (!$entityType->hasLinkTemplate('version-history')) { + return NULL; + } + + $entityTypeId = $entityType->id(); + return (new Route($entityType->getLinkTemplate('version-history'))) + ->addDefaults([ + '_controller' => VersionHistoryController::class, + '_title' => 'Revisions', + ]) + ->setRequirement('_entity_access', $entityTypeId . '.view all revisions') + ->setOption('entity_type_id', $entityTypeId) + ->setOption('_admin_route', TRUE) + ->setOption('parameters', [ + $entityTypeId => [ + 'type' => 'entity:' . $entityTypeId, + ], + ]); + } + + /** + * Gets the entity revision view route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entityType + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The entity revision view route, or NULL if the entity type does not + * support viewing revisions. + */ + protected function getRevisionViewRoute(EntityTypeInterface $entityType): ?Route { + if (!$entityType->hasLinkTemplate('revision')) { + return NULL; + } + + $entityTypeId = $entityType->id(); + $revisionParameterName = $entityTypeId . '_revision'; + return (new Route($entityType->getLinkTemplate('revision'))) + ->addDefaults([ + '_controller' => EntityRevisionViewController::class, + '_title_callback' => EntityRevisionViewController::class . '::title', + ]) + ->setRequirement('_entity_access', $revisionParameterName . '.view revision') + ->setOption('parameters', [ + $entityTypeId => [ + 'type' => 'entity:' . $entityTypeId, + ], + $revisionParameterName => [ + 'type' => 'entity_revision:' . $entityTypeId, + ], + ]); + } + + /** + * Gets the entity revision revert route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entityType + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The entity revision revert route, or NULL if the entity type does not + * support reverting revisions. + */ + protected function getRevisionRevertRoute(EntityTypeInterface $entityType): ?Route { + if (!$entityType->hasLinkTemplate('revision-revert-form')) { + return NULL; + } + + $entityTypeId = $entityType->id(); + $revisionParameterName = $entityTypeId . '_revision'; + return (new Route($entityType->getLinkTemplate('revision-revert-form'))) + ->addDefaults([ + '_entity_form' => $entityTypeId . '.revision-revert', + '_title' => 'Revert revision', + ]) + ->setRequirement('_entity_access', $revisionParameterName . '.revert') + ->setOption('_admin_route', TRUE) + ->setOption('parameters', [ + $entityTypeId => [ + 'type' => 'entity:' . $entityTypeId, + ], + $revisionParameterName => [ + 'type' => 'entity_revision:' . $entityTypeId, + ], + ]); + } + + /** + * Gets the entity revision delete route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entityType + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The entity revision delete route, or NULL if the entity type does not + * support deleting revisions. + */ + protected function getRevisionDeleteRoute(EntityTypeInterface $entityType): ?Route { + if (!$entityType->hasLinkTemplate('revision-delete-form')) { + return NULL; + } + + $entityTypeId = $entityType->id(); + $revisionParameterName = $entityTypeId . '_revision'; + return (new Route($entityType->getLinkTemplate('revision-delete-form'))) + ->addDefaults([ + '_entity_form' => $entityTypeId . '.revision-delete', + '_title' => 'Delete revision', + ]) + ->setRequirement('_entity_access', $revisionParameterName . '.delete revision') + ->setOption('_admin_route', TRUE) + ->setOption('parameters', [ + $entityTypeId => [ + 'type' => 'entity:' . $entityTypeId, + ], + $revisionParameterName => [ + 'type' => 'entity_revision:' . $entityTypeId, + ], + ]); + } + +} diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index 5dc97324d7fe44ee2e20d3be7f24c6c1b8b4c215..03a5c5afe86121ce4f56564612a7598637a1a9a1 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -1,7 +1,7 @@ entity.media.revision: path: '/media/{media}/revisions/{media_revision}/view' defaults: - _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision' + _controller: '\Drupal\Core\Entity\Controller\EntityRevisionViewController' _title_callback: '\Drupal\Core\Entity\Controller\EntityController::title' options: parameters: diff --git a/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php b/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php index 43d9ebda0b13878d0e3d19c5044a1c27e05817d9..b2ad9d4e35abae31b830e94071e25e10273b3513 100644 --- a/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php +++ b/core/modules/migrate/tests/src/Kernel/MigrateEntityContentValidationTest.php @@ -86,12 +86,12 @@ public function test1() { ], [ 'id' => '2', - 'name' => $this->randomString(32), + 'name' => $this->randomString(64), 'user_id' => '1', ], [ 'id' => '3', - 'name' => $this->randomString(32), + 'name' => $this->randomString(64), 'user_id' => '2', ], ], @@ -110,7 +110,7 @@ public function test1() { ], ]); - $this->assertSame('1: [entity_test: 1]: name.0.value=<em class="placeholder">Name</em>: may not be longer than 32 characters.||user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[0], 'First message should have 2 validation errors.'); + $this->assertSame('1: [entity_test: 1]: name.0.value=<em class="placeholder">Name</em>: may not be longer than 64 characters.||user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[0], 'First message should have 2 validation errors.'); $this->assertSame('2: [entity_test: 2]: user_id.0.target_id=The referenced entity (<em class="placeholder">user</em>: <em class="placeholder">1</em>) does not exist.', $this->messages[1], 'Second message should have 1 validation error.'); $this->assertArrayNotHasKey(2, $this->messages, 'Third message should not exist.'); } diff --git a/core/modules/node/node.module b/core/modules/node/node.module index eb3050e82ff91c6f385dd18e152c61d6101d4312..e42f548422869b07fec24a47381e317bf9d82752 100644 --- a/core/modules/node/node.module +++ b/core/modules/node/node.module @@ -177,6 +177,17 @@ function node_title_list(StatementInterface $result, $title = NULL) { return $num_rows ? ['#theme' => 'item_list__node', '#items' => $items, '#title' => $title, '#cache' => ['tags' => Cache::mergeTags(['node_list'], Cache::buildTags('node', $nids))]] : FALSE; } +/** + * Implements hook_local_tasks_alter(). + */ +function node_local_tasks_alter(&$local_tasks): void { + // Removes 'Revisions' local task added by deriver. Local task + // 'entity.node.version_history' will be replaced by + // 'entity.version_history:node.version_history' after + // https://www.drupal.org/project/drupal/issues/3153559. + unset($local_tasks['entity.version_history:node.version_history']); +} + /** * Determines the type of marker to be displayed for a given node. * diff --git a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php index 833cef792df1c07ac291367a780725b6c3dcf17c..a19e0f0426730d43ff4ecf8507e837526cfa6f54 100644 --- a/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php +++ b/core/modules/node/tests/src/Functional/NodeRevisionsUiTest.php @@ -19,6 +19,11 @@ class NodeRevisionsUiTest extends NodeTestBase { */ protected $defaultTheme = 'stark'; + /** + * {@inheritdoc} + */ + protected static $modules = ['block']; + /** * @var \Drupal\user\Entity\User */ @@ -187,4 +192,27 @@ public function testNodeRevisionsTabWithDefaultRevision() { $this->assertSession()->linkByHrefNotExists('/node/' . $node_id . '/revisions/5/revert'); } + /** + * Checks the Revisions tab. + * + * Tests two 'Revisions' local tasks are not added by both Node and + * VersionHistoryLocalTasks. + * + * This can be removed after 'entity.node.version_history' local task is + * removed by https://www.drupal.org/project/drupal/issues/3153559. + * + * @covers node_local_tasks_alter() + */ + public function testNodeDuplicateRevisionsTab(): void { + $this->drupalPlaceBlock('local_tasks_block'); + $this->drupalLogin($this->editor); + + $node = $this->drupalCreateNode(); + $this->drupalGet($node->toUrl('edit-form')); + + // There must be exactly one 'Revisions' local task. + $xpath = $this->assertSession()->buildXPathQuery('//a[contains(@href, :href)]', [':href' => $node->toUrl('version-history')->toString()]); + $this->assertSession()->elementsCount('xpath', $xpath, 1); + } + } diff --git a/core/modules/system/system.links.task.yml b/core/modules/system/system.links.task.yml index db8f8564d822f91e931fa8e03fe821aadfb14727..ed66b0595e20b1316f4a42a1123014cb8ea75122 100644 --- a/core/modules/system/system.links.task.yml +++ b/core/modules/system/system.links.task.yml @@ -64,6 +64,11 @@ entity.date_format.edit_form: route_name: entity.date_format.edit_form base_route: entity.date_format.edit_form +entity.version_history: + title: 'Revisions' + weight: 20 + deriver: 'Drupal\Core\Entity\Plugin\Derivative\VersionHistoryLocalTasks' + system.admin_content: title: Content route_name: system.admin_content diff --git a/core/modules/system/tests/modules/entity_test/entity_test.routing.yml b/core/modules/system/tests/modules/entity_test/entity_test.routing.yml index f08c0902d8ec87560c62b66e1a1b752d910a112f..52278e4d7806c7b42c3103828ca49d379b0fb0e0 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.routing.yml +++ b/core/modules/system/tests/modules/entity_test/entity_test.routing.yml @@ -55,23 +55,10 @@ entity.entity_test.collection: requirements: _access: 'TRUE' -entity.entity_test_rev.revision: - path: '/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view' - defaults: - _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision' - options: - parameters: - entity_test_rev: - type: entity:entity_test_rev - entity_test_rev_revision: - type: entity_revision:entity_test_rev - requirements: - _access: 'TRUE' - entity.entity_test_mulrev.revision: path: '/entity_test_mulrev/{entity_test_mulrev}/revision/{entity_test_mulrev_revision}/view' defaults: - _controller: '\Drupal\Core\Entity\Controller\EntityViewController::viewRevision' + _controller: '\Drupal\Core\Entity\Controller\EntityRevisionViewController' options: parameters: entity_test_mulrev: diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php index ecf193b9d06e339fdcf1de53bc9902143bb106b6..cdff2c884c085775419f874816eeb0b24162ad4f 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTest.php @@ -73,7 +73,7 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setLabel(t('Name')) ->setDescription(t('The name of the test entity.')) ->setTranslatable(TRUE) - ->setSetting('max_length', 32) + ->setSetting('max_length', 64) ->setDisplayOptions('view', [ 'label' => 'hidden', 'type' => 'string', diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php index e47e9362767123a3acd8ea56ec26db8cb084b753..0c1326b66244d2379da2ea62ab1ca5a05af7b3e0 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRev.php @@ -17,11 +17,14 @@ * "form" = { * "default" = "Drupal\entity_test\EntityTestForm", * "delete" = "Drupal\entity_test\EntityTestDeleteForm", - * "delete-multiple-confirm" = "Drupal\Core\Entity\Form\DeleteMultipleForm" + * "delete-multiple-confirm" = \Drupal\Core\Entity\Form\DeleteMultipleForm::class, + * "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class, + * "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class, * }, * "views_data" = "Drupal\views\EntityViewsData", * "route_provider" = { * "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider", + * "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class, * }, * }, * base_table = "entity_test_rev", @@ -43,6 +46,9 @@ * "delete-multiple-form" = "/entity_test_rev/delete_multiple", * "edit-form" = "/entity_test_rev/manage/{entity_test_rev}/edit", * "revision" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view", + * "revision-delete-form" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/delete", + * "revision-revert-form" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/revert", + * "version-history" = "/entity_test_rev/{entity_test_rev}/revisions", * } * ) */ diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php index 3c3a201d589d25d9b64c2bcb21b6b07bfab77d9c..8b961601355ec9be24b4499d2e76401a2703cd1d 100644 --- a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestRevPub.php @@ -18,7 +18,13 @@ * "form" = { * "default" = "Drupal\entity_test\EntityTestForm", * "delete" = "Drupal\entity_test\EntityTestDeleteForm", - * "delete-multiple-confirm" = "Drupal\Core\Entity\Form\DeleteMultipleForm" + * "delete-multiple-confirm" = \Drupal\Core\Entity\Form\DeleteMultipleForm::class, + * "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class, + * "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class, + * }, + * "route_provider" = { + * "html" = \Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider::class, + * "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class, * }, * }, * base_table = "entity_test_revpub", @@ -35,12 +41,15 @@ * "published" = "status", * }, * links = { - * "add-form" = "/entity_test_rev/add", - * "canonical" = "/entity_test_rev/manage/{entity_test_rev}", - * "delete-form" = "/entity_test/delete/entity_test_rev/{entity_test_rev}", - * "delete-multiple-form" = "/entity_test_rev/delete_multiple", - * "edit-form" = "/entity_test_rev/manage/{entity_test_rev}/edit", - * "revision" = "/entity_test_rev/{entity_test_rev}/revision/{entity_test_rev_revision}/view", + * "add-form" = "/entity_test_revpub/add", + * "canonical" = "/entity_test_revpub/manage/{entity_test_revpub}", + * "delete-form" = "/entity_test/delete/entity_test_revpub/{entity_test_revpub}", + * "delete-multiple-form" = "/entity_test_revpub/delete_multiple", + * "edit-form" = "/entity_test_revpub/manage/{entity_test_revpub}/edit", + * "revision" = "/entity_test_revpub/{entity_test_revpub}/revision/{entity_test_revpub_revision}/view", + * "revision-delete-form" = "/entity_test_revpub/{entity_test_revpub}/revision/{entity_test_revpub_revision}/delete", + * "revision-revert-form" = "/entity_test_revpub/{entity_test_revpub}/revision/{entity_test_revpub_revision}/revert", + * "version-history" = "/entity_test_revpub/{entity_test_revpub}/revisions", * } * ) */ diff --git a/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php b/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php index 4e7b4dc2a0475f2c61e1466371fd918c056f4d82..287be829cadaae8c8cd8f85994c9f4eb957a7873 100644 --- a/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php +++ b/core/modules/system/tests/modules/entity_test/src/EntityTestAccessControlHandler.php @@ -65,9 +65,27 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter return $access; } + // Access to revisions is based on labels, so access can vary by individual + // revisions, since the 'name' field can vary by revision. + $labels = explode(',', $entity->label()); + $labels = array_map('trim', $labels); + if (in_array($operation, [ + 'view all revisions', + 'view revision', + ], TRUE)) { + return AccessResult::allowedIf(in_array($operation, $labels, TRUE)); + } + elseif ($operation === 'revert') { + // Disallow reverting to latest. + return AccessResult::allowedIf(!$entity->isDefaultRevision() && !$entity->isLatestRevision() && in_array('revert', $labels, TRUE)); + } + elseif ($operation === 'delete revision') { + // Disallow deleting latest and current revision. + return AccessResult::allowedIf(!$entity->isLatestRevision() && in_array('delete revision', $labels, TRUE)); + } + // No opinion. return AccessResult::neutral(); - } /** diff --git a/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php b/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php index b03415ff58adcc7d96df8446ac620dc27852d091..c5430498b766ba47cc2218db143b78581b2f70da 100644 --- a/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php +++ b/core/modules/system/tests/modules/entity_test_revlog/src/Entity/EntityTestWithRevisionLog.php @@ -12,21 +12,44 @@ * @ContentEntityType( * id = "entity_test_revlog", * label = @Translation("Test entity - revisions log"), + * handlers = { + * "access" = "Drupal\entity_test_revlog\EntityTestRevlogAccessControlHandler", + * "form" = { + * "default" = \Drupal\Core\Entity\ContentEntityForm::class, + * "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class, + * "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class, + * }, + * "route_provider" = { + * "html" = \Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider::class, + * "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class, + * }, + * }, * base_table = "entity_test_revlog", * revision_table = "entity_test_revlog_revision", + * translatable = FALSE, * entity_keys = { * "id" = "id", * "uuid" = "uuid", * "revision" = "revision_id", * "bundle" = "type", * "label" = "name", - * "langcode" = "langcode", * }, * revision_metadata_keys = { * "revision_user" = "revision_user", * "revision_created" = "revision_created", * "revision_log_message" = "revision_log_message" * }, + * links = { + * "add-form" = "/entity_test_revlog/add", + * "canonical" = "/entity_test_revlog/manage/{entity_test_revlog}", + * "delete-form" = "/entity_test/delete/entity_test_revlog/{entity_test_revlog}", + * "delete-multiple-form" = "/entity_test_revlog/delete_multiple", + * "edit-form" = "/entity_test_revlog/manage/{entity_test_revlog}/edit", + * "revision" = "/entity_test_revlog/{entity_test_revlog}/revision/{entity_test_revlog_revision}/view", + * "revision-delete-form" = "/entity_test_revlog/{entity_test_revlog}/revision/{entity_test_revlog_revision}/delete", + * "revision-revert-form" = "/entity_test_revlog/{entity_test_revlog}/revision/{entity_test_revlog_revision}/revert", + * "version-history" = "/entity_test_revlog/{entity_test_revlog}/revisions", + * } * ) */ class EntityTestWithRevisionLog extends RevisionableContentEntityBase { @@ -40,9 +63,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { $fields['name'] = BaseFieldDefinition::create('string') ->setLabel(t('Name')) ->setDescription(t('The name of the test entity.')) - ->setTranslatable(TRUE) ->setRevisionable(TRUE) - ->setSetting('max_length', 32) + ->setSetting('max_length', 64) ->setDisplayOptions('view', [ 'label' => 'hidden', 'type' => 'string', @@ -56,4 +78,17 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { return $fields; } + /** + * Sets the name. + * + * @param string $name + * Name of the entity. + * + * @return $this + */ + public function setName(string $name) { + $this->set('name', $name); + return $this; + } + } diff --git a/core/modules/system/tests/modules/entity_test_revlog/src/EntityTestRevlogAccessControlHandler.php b/core/modules/system/tests/modules/entity_test_revlog/src/EntityTestRevlogAccessControlHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..a5275600bc6170d877208780d68381f1ad6d42de --- /dev/null +++ b/core/modules/system/tests/modules/entity_test_revlog/src/EntityTestRevlogAccessControlHandler.php @@ -0,0 +1,55 @@ +<?php + +namespace Drupal\entity_test_revlog; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityAccessControlHandler; +use Drupal\Core\Session\AccountInterface; +use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog; + +/** + * Defines the access control handler for test entity types. + */ +class EntityTestRevlogAccessControlHandler extends EntityAccessControlHandler { + + /** + * {@inheritdoc} + */ + protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) { + assert($entity instanceof EntityTestWithRevisionLog); + + // Access to revisions is based on labels, so access can vary by individual + // revisions, since the 'name' field can vary by revision. + $labels = explode(',', $entity->label()); + $labels = array_map('trim', $labels); + if (in_array($operation, [ + 'view', + 'view label', + 'view all revisions', + 'view revision', + ], TRUE)) { + return AccessResult::allowedIf(in_array($operation, $labels, TRUE)); + } + elseif ($operation === 'revert') { + return AccessResult::allowedIf( + // Allow revert even if latest. + in_array('force allow revert', $labels, TRUE) || + // Disallow reverting to latest. + (!$entity->isDefaultRevision() && !$entity->isLatestRevision() && in_array('revert', $labels, TRUE)) + ); + } + elseif ($operation === 'delete revision') { + return AccessResult::allowedIf( + // Allow revision deletion even if latest. + in_array('force allow delete revision', $labels, TRUE) || + // Disallow deleting latest and current revision. + (!$entity->isLatestRevision() && in_array('delete revision', $labels, TRUE)) + ); + } + + // No opinion. + return AccessResult::neutral(); + } + +} diff --git a/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php b/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php index 6c0f802f6e4018ac5a800df5a539d50348d88aae..3ce683ec9287d02c2be1d4a9c33f89ac3c5b7b56 100644 --- a/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php +++ b/core/modules/system/tests/src/Functional/Entity/EntityViewControllerTest.php @@ -38,7 +38,7 @@ protected function setUp(): void { parent::setUp(); // Create some dummy entity_test entities. for ($i = 0; $i < 2; $i++) { - $entity_test = $this->createTestEntity('entity_test'); + $entity_test = $this->createTestEntity('entity_test', 'view revision'); $entity_test->save(); $this->entities[] = $entity_test; } @@ -78,7 +78,7 @@ public function testEntityViewController() { $entity_test_rev->setNewRevision(TRUE); $entity_test_rev->isDefaultRevision(TRUE); $entity_test_rev->save(); - $this->drupalGet('entity_test_rev/' . $entity_test_rev->id() . '/revision/' . $entity_test_rev->revision_id->value . '/view'); + $this->drupalGet($entity_test_rev->toUrl('revision')); $this->assertSession()->pageTextContains($entity_test_rev->label()); $this->assertSession()->responseContains($get_label_markup($entity_test_rev->label())); @@ -125,14 +125,16 @@ public function testEntityViewControllerViewBuilder() { * * @param string $entity_type * The entity type. + * @param string|null $name + * The entity name, or NULL to generate random name. * * @return \Drupal\Core\Entity\EntityInterface * The created entity. */ - protected function createTestEntity($entity_type) { + protected function createTestEntity($entity_type, $name = NULL) { $data = [ 'bundle' => $entity_type, - 'name' => $this->randomMachineName(), + 'name' => $name ?? $this->randomMachineName(), ]; return $this->container->get('entity_type.manager')->getStorage($entity_type)->create($data); } diff --git a/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php b/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php index 55d79ec9fba02f3959ea9620ffdd4b92fb52752f..ab0ff1186543233069ec73d17498d2dc6e6a7d83 100644 --- a/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php +++ b/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\system\Unit\Menu; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Extension\Extension; use Drupal\Tests\Core\Menu\LocalTaskIntegrationTestBase; @@ -15,7 +17,7 @@ class SystemLocalTasksTest extends LocalTaskIntegrationTestBase { /** * The mocked theme handler. * - * @var \Drupal\Core\Extension\ThemeHandlerInterface|\PHPUnit\Framework\MockObject\MockObject + * @var \Drupal\Core\Extension\ThemeHandlerInterface */ protected $themeHandler; @@ -44,6 +46,20 @@ protected function setUp(): void { ->with('olivero') ->willReturn(TRUE); $this->container->set('theme_handler', $this->themeHandler); + + $fooEntityDefinition = $this->createMock(EntityTypeInterface::class); + $fooEntityDefinition + ->expects($this->once()) + ->method('hasLinkTemplate') + ->with('version-history') + ->will($this->returnValue(TRUE)); + $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class); + $entityTypeManager->expects($this->any()) + ->method('getDefinitions') + ->willReturn([ + 'foo' => $fooEntityDefinition, + ]); + $this->container->set('entity_type.manager', $entityTypeManager); } /** @@ -68,6 +84,12 @@ public function getSystemAdminRoutes() { ['system.theme_settings_global', 'system.theme_settings_theme:olivero'], ], ], + [ + 'entity.foo.version_history', + [ + ['entity.version_history:foo.version_history'], + ], + ], ]; } diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php new file mode 100644 index 0000000000000000000000000000000000000000..17372ca8270e68502746dff2465936fe22acf8bb --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionDeleteFormTest.php @@ -0,0 +1,308 @@ +<?php + +namespace Drupal\FunctionalTests\Entity; + +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\entity_test\Entity\EntityTestRev; +use Drupal\entity_test\Entity\EntityTestRevPub; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests deleting a revision with revision delete form. + * + * @group Entity + * @coversDefaultClass \Drupal\Core\Entity\Form\RevisionDeleteForm + */ +class RevisionDeleteFormTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'entity_test', + 'entity_test_revlog', + 'dblog', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->drupalPlaceBlock('page_title_block'); + } + + /** + * Tests title by whether entity supports revision creation dates. + * + * @param string $entityTypeId + * The entity type to test. + * @param string $expectedQuestion + * The expected question/page title. + * + * @covers ::getQuestion + * @dataProvider providerPageTitle + */ + public function testPageTitle(string $entityTypeId, string $expectedQuestion): void { + $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId); + + $entity = $storage->create([ + 'type' => $entityTypeId, + 'name' => 'delete revision', + ]); + if ($entity instanceof RevisionLogInterface) { + $date = new \DateTime('11 January 2009 4:00:00pm'); + $entity->setRevisionCreationTime($date->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + $revisionId = $entity->getRevisionId(); + + // Create a new latest revision. + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + + // Reload the entity. + $revision = $storage->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision-delete-form')); + $this->assertSession()->pageTextContains($expectedQuestion); + $this->assertSession()->buttonExists('Delete'); + $this->assertSession()->linkExists('Cancel'); + } + + /** + * Data provider for testPageTitle. + */ + public function providerPageTitle(): array { + return [ + ['entity_test_rev', 'Are you sure you want to delete the revision?'], + ['entity_test_revlog', 'Are you sure you want to delete the revision from Sun, 01/11/2009 - 16:00?'], + ]; + } + + /** + * Test cannot delete latest revision. + * + * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess + */ + public function testAccessDeleteLatest(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(); + $entity->setName('delete revision'); + $entity->save(); + + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('revision-delete-form')); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Test cannot delete default revision. + * + * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess + */ + public function testAccessDeleteDefault(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRevPub $entity */ + $entity = EntityTestRevPub::create(); + $entity->setName('delete revision'); + $entity->save(); + + $entity->isDefaultRevision(TRUE); + $entity->setPublished(); + $entity->setNewRevision(); + $entity->save(); + $revisionId = $entity->getRevisionId(); + + $entity->isDefaultRevision(FALSE); + $entity->setUnpublished(); + $entity->setNewRevision(); + $entity->save(); + + // Reload the entity. + /** @var \Drupal\entity_test\Entity\EntityTestRevPub $revision */ + $revision = \Drupal::entityTypeManager()->getStorage('entity_test_revpub') + ->loadRevision($revisionId); + // Check default but not latest. + $this->assertTrue($revision->isDefaultRevision()); + $this->assertFalse($revision->isLatestRevision()); + $this->drupalGet($entity->toUrl('revision-delete-form')); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Test can delete non-latest revision. + * + * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess + */ + public function testAccessDeleteNonLatest(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(); + $entity->setName('delete revision'); + $entity->save(); + $entity->isDefaultRevision(); + $revisionId = $entity->getRevisionId(); + + $entity->setNewRevision(); + $entity->save(); + + // Reload the entity. + $revision = \Drupal::entityTypeManager()->getStorage('entity_test_rev') + ->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision-delete-form')); + $this->assertSession()->statusCodeEquals(200); + } + + /** + * Tests revision deletion, and expected response after deletion. + * + * @param array $permissions + * If not empty, a user will be created and logged in with these + * permissions. + * @param string $entityTypeId + * The entity type to test. + * @param string $entityLabel + * The entity label, which corresponds to access grants. + * @param int $totalRevisions + * Total number of revisions to create. + * @param string $expectedLog + * Expected log. + * @param string $expectedMessage + * Expected messenger message. + * @param string|int $expectedDestination + * Expected destination after deletion. + * + * @covers ::submitForm + * @dataProvider providerSubmitForm + */ + public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, int $totalRevisions, string $expectedLog, string $expectedMessage, $expectedDestination): void { + if (count($permissions) > 0) { + $this->drupalLogin($this->createUser($permissions)); + } + $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId); + + $entity = $storage->create([ + 'type' => $entityTypeId, + 'name' => $entityLabel, + ]); + if ($entity instanceof RevisionLogInterface) { + $date = new \DateTime('11 January 2009 4:00:00pm'); + $entity->setRevisionCreationTime($date->getTimestamp()); + } + $entity->save(); + $revisionId = $entity->getRevisionId(); + + $otherRevisionIds = []; + for ($i = 0; $i < $totalRevisions - 1; $i++) { + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + $otherRevisionIds[] = $entity->getRevisionId(); + } + + $revision = $storage->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision-delete-form')); + $this->submitForm([], 'Delete'); + + // The revision was deleted. + $this->assertNull($storage->loadRevision($revisionId)); + // Make sure the other revisions were not deleted. + foreach ($otherRevisionIds as $otherRevisionId) { + $this->assertNotNull($storage->loadRevision($otherRevisionId)); + } + + // Destination. + if ($expectedDestination === 404) { + $this->assertSession()->statusCodeEquals(404); + } + else { + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals($expectedDestination); + } + + // Logger log. + $logs = $this->getLogs($entity->getEntityType()->getProvider()); + $this->assertEquals([0 => $expectedLog], $logs); + // Messenger message. + $this->assertSession()->pageTextContains($expectedMessage); + } + + /** + * Data provider for testSubmitForm. + */ + public function providerSubmitForm(): array { + $data = []; + + $data['not supporting revision log, one revision remaining after delete, no view access'] = [ + [], + 'entity_test_rev', + 'view all revisions, delete revision', + 2, + 'entity_test_rev: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + 'Revision of Entity Test Bundle view all revisions, delete revision has been deleted.', + '/entity_test_rev/1/revisions', + ]; + + $data['not supporting revision log, one revision remaining after delete, view access'] = [ + ['view test entity'], + 'entity_test_rev', + 'view, view all revisions, delete revision', + 2, + 'entity_test_rev: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + 'Revision of Entity Test Bundle view, view all revisions, delete revision has been deleted.', + '/entity_test_rev/1/revisions', + ]; + + $data['supporting revision log, one revision remaining after delete, no view access'] = [ + [], + 'entity_test_revlog', + 'view all revisions, delete revision', + 2, + 'entity_test_revlog: deleted <em class="placeholder">view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + 'Revision from Sun, 01/11/2009 - 16:00 of Test entity - revisions log view all revisions, delete revision has been deleted.', + '/entity_test_revlog/1/revisions', + ]; + + $data['supporting revision log, one revision remaining after delete, view access'] = [ + [], + 'entity_test_revlog', + 'view, view all revisions, delete revision', + 2, + 'entity_test_revlog: deleted <em class="placeholder">view, view all revisions, delete revision</em> revision <em class="placeholder">1</em>.', + 'Revision from Sun, 01/11/2009 - 16:00 of Test entity - revisions log view, view all revisions, delete revision has been deleted.', + '/entity_test_revlog/1/revisions', + ]; + + return $data; + } + + /** + * Loads watchdog entries by channel. + * + * @param string $channel + * The logger channel. + * + * @return string[] + * Watchdog entries. + */ + protected function getLogs(string $channel): array { + $logs = \Drupal::database()->query("SELECT * FROM {watchdog} WHERE type = :type", [':type' => $channel])->fetchAll(); + return array_map(function (object $log) { + return (string) new FormattableMarkup($log->message, unserialize($log->variables)); + }, $logs); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php new file mode 100644 index 0000000000000000000000000000000000000000..82d0d06d8c2b13f979d8bfe0d407556d87c08bf5 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRevertFormTest.php @@ -0,0 +1,330 @@ +<?php + +namespace Drupal\FunctionalTests\Entity; + +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\entity_test\Entity\EntityTestRev; +use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests reverting a revision with revision revert form. + * + * @group Entity + * @coversDefaultClass \Drupal\Core\Entity\Form\RevisionRevertForm + */ +class RevisionRevertFormTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'entity_test', + 'entity_test_revlog', + 'dblog', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->drupalPlaceBlock('page_title_block'); + } + + /** + * Tests title by whether entity supports revision creation dates. + * + * @param string $entityTypeId + * The entity type to test. + * @param string $expectedQuestion + * The expected question/page title. + * + * @covers ::getQuestion + * @dataProvider providerPageTitle + */ + public function testPageTitle(string $entityTypeId, string $expectedQuestion): void { + $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId); + + $entity = $storage->create([ + 'type' => $entityTypeId, + 'name' => 'revert', + ]); + if ($entity instanceof RevisionLogInterface) { + $date = new \DateTime('11 January 2009 4:00:00pm'); + $entity->setRevisionCreationTime($date->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + $revisionId = $entity->getRevisionId(); + + // Create a new latest revision. + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + + // Reload the entity. + $revision = $storage->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision-revert-form')); + $this->assertSession()->pageTextContains($expectedQuestion); + $this->assertSession()->buttonExists('Revert'); + $this->assertSession()->linkExists('Cancel'); + } + + /** + * Data provider for testPageTitle. + */ + public function providerPageTitle(): array { + return [ + ['entity_test_rev', 'Are you sure you want to revert the revision?'], + ['entity_test_revlog', 'Are you sure you want to revert to the revision from Sun, 01/11/2009 - 16:00?'], + ]; + } + + /** + * Test cannot revert latest revision. + * + * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess + */ + public function testAccessRevertLatest(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(); + $entity->setName('revert'); + $entity->save(); + + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('revision-revert-form')); + $this->assertSession()->statusCodeEquals(403); + } + + /** + * Test can revert non-latest revision. + * + * @covers \Drupal\Core\Entity\EntityRevisionAccessCheck::checkAccess + */ + public function testAccessRevertNonLatest(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(); + $entity->setName('revert'); + $entity->save(); + $revisionId = $entity->getRevisionId(); + + $entity->setNewRevision(); + $entity->save(); + + // Reload the entity. + $revision = \Drupal::entityTypeManager()->getStorage('entity_test_rev') + ->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision-revert-form')); + $this->assertSession()->statusCodeEquals(200); + } + + /** + * Tests revision revert, and expected response after revert. + * + * @param array $permissions + * If not empty, a user will be created and logged in with these + * permissions. + * @param string $entityTypeId + * The entity type to test. + * @param string $entityLabel + * The entity label, which corresponds to access grants. + * @param string $expectedLog + * Expected log. + * @param string $expectedMessage + * Expected messenger message. + * @param string $expectedDestination + * Expected destination after deletion. + * + * @covers ::submitForm + * @dataProvider providerSubmitForm + */ + public function testSubmitForm(array $permissions, string $entityTypeId, string $entityLabel, string $expectedLog, string $expectedMessage, string $expectedDestination): void { + if (count($permissions) > 0) { + $this->drupalLogin($this->createUser($permissions)); + } + $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId); + + $entity = $storage->create([ + 'type' => $entityTypeId, + 'name' => $entityLabel, + ]); + if ($entity instanceof RevisionLogInterface) { + $date = new \DateTime('11 January 2009 4:00:00pm'); + $entity->setRevisionCreationTime($date->getTimestamp()); + } + $entity->save(); + $revisionId = $entity->getRevisionId(); + + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + + $revision = $storage->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision-revert-form')); + + $count = $this->countRevisions($entityTypeId); + $this->submitForm([], 'Revert'); + + // A new revision was created. + $this->assertEquals($count + 1, $this->countRevisions($entityTypeId)); + + // Destination. + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals($expectedDestination); + + // Logger log. + $logs = $this->getLogs($entity->getEntityType()->getProvider()); + $this->assertEquals([0 => $expectedLog], $logs); + // Messenger message. + $this->assertSession()->pageTextContains($expectedMessage); + } + + /** + * Data provider for testSubmitForm. + */ + public function providerSubmitForm(): array { + $data = []; + + $data['not supporting revision log, no version history access'] = [ + ['view test entity'], + 'entity_test_rev', + 'view, revert', + 'entity_test_rev: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.', + 'Entity Test Bundle view, revert has been reverted.', + '/entity_test_rev/manage/1', + ]; + + $data['not supporting revision log, version history access'] = [ + ['view test entity'], + 'entity_test_rev', + 'view, view all revisions, revert', + 'entity_test_rev: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.', + 'Entity Test Bundle view, view all revisions, revert has been reverted.', + '/entity_test_rev/1/revisions', + ]; + + $data['supporting revision log, no version history access'] = [ + [], + 'entity_test_revlog', + 'view, revert', + 'entity_test_revlog: reverted <em class="placeholder">view, revert</em> revision <em class="placeholder">1</em>.', + 'Test entity - revisions log view, revert has been reverted to the revision from Sun, 01/11/2009 - 16:00.', + '/entity_test_revlog/manage/1', + ]; + + $data['supporting revision log, version history access'] = [ + [], + 'entity_test_revlog', + 'view, view all revisions, revert', + 'entity_test_revlog: reverted <em class="placeholder">view, view all revisions, revert</em> revision <em class="placeholder">1</em>.', + 'Test entity - revisions log view, view all revisions, revert has been reverted to the revision from Sun, 01/11/2009 - 16:00.', + '/entity_test_revlog/1/revisions', + ]; + + return $data; + } + + /** + * Tests the revert process. + * + * @covers ::prepareRevision + */ + public function testPrepareRevision(): void { + $user = $this->createUser(); + $this->drupalLogin($user); + + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create([ + 'type' => 'entity_test_revlog', + 'name' => 'revert', + ]); + + $date = new \DateTime('11 January 2009 4:00:00pm'); + $entity->setRevisionCreationTime($date->getTimestamp()); + $entity->isDefaultRevision(TRUE); + $entity->setNewRevision(); + $entity->save(); + + $revisionCreationTime = $date->modify('+1 hour')->getTimestamp(); + $entity->setRevisionCreationTime($revisionCreationTime); + $entity->setRevisionUserId(0); + $entity->isDefaultRevision(FALSE); + $entity->setNewRevision(); + $entity->save(); + $targetRevertRevisionId = $entity->getRevisionId(); + + // Create a another revision so the previous revision can be reverted to. + $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp()); + $entity->isDefaultRevision(FALSE); + $entity->setNewRevision(); + $entity->save(); + + $count = $this->countRevisions($entity->getEntityTypeId()); + + // Load the revision to be copied. + $storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId()); + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $targetRevision */ + $targetRevision = $storage->loadRevision($targetRevertRevisionId); + + $this->drupalGet($targetRevision->toUrl('revision-revert-form')); + $this->submitForm([], 'Revert'); + + // Load the new latest revision. + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $latestRevision */ + $latestRevision = $storage->loadUnchanged($entity->id()); + $this->assertEquals($count + 1, $this->countRevisions($entity->getEntityTypeId())); + $this->assertEquals('Copy of the revision from <em class="placeholder">Sun, 01/11/2009 - 17:00</em>.', $latestRevision->getRevisionLogMessage()); + $this->assertGreaterThan($revisionCreationTime, $latestRevision->getRevisionCreationTime()); + $this->assertEquals($user->id(), $latestRevision->getRevisionUserId()); + $this->assertTrue($latestRevision->isDefaultRevision()); + } + + /** + * Loads watchdog entries by channel. + * + * @param string $channel + * The logger channel. + * + * @return string[] + * Watchdog entries. + */ + protected function getLogs(string $channel): array { + $logs = \Drupal::database()->query("SELECT * FROM {watchdog} WHERE type = :type", [':type' => $channel])->fetchAll(); + return array_map(function (object $log) { + return (string) new FormattableMarkup($log->message, unserialize($log->variables)); + }, $logs); + } + + /** + * Count number of revisions for an entity type. + * + * @param string $entityTypeId + * The entity type. + * + * @return int + * Number of revisions for an entity type. + */ + protected function countRevisions(string $entityTypeId): int { + return (int) \Drupal::entityTypeManager()->getStorage($entityTypeId) + ->getQuery() + ->accessCheck(FALSE) + ->allRevisions() + ->count() + ->execute(); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionRouteProviderTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionRouteProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..56062dade42ec941ac8bb72e9b35e8b03ecf8b7c --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionRouteProviderTest.php @@ -0,0 +1,63 @@ +<?php + +namespace Drupal\FunctionalTests\Entity; + +use Drupal\entity_test\Entity\EntityTestRev; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests revision route provider. + * + * @group Entity + * @coversDefaultClass \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider + */ +class RevisionRouteProviderTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'entity_test', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->drupalPlaceBlock('page_title_block'); + } + + /** + * Tests title is from revision in context. + */ + public function testRevisionTitle(): void { + $entity = EntityTestRev::create(); + $entity + ->setName('first revision, view revision') + ->setNewRevision(); + $entity->save(); + $revisionId = $entity->getRevisionId(); + + // A default revision is created to ensure it is not pulled from the + // non-revision entity parameter. + $entity + ->setName('second revision, view revision') + ->setNewRevision(); + $entity->isDefaultRevision(TRUE); + $entity->save(); + + // Reload the object. + $revision = \Drupal::entityTypeManager()->getStorage('entity_test_rev')->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision')); + $this->assertSession()->responseContains('first revision'); + $this->assertSession()->responseNotContains('second revision'); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionVersionHistoryTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionVersionHistoryTest.php new file mode 100644 index 0000000000000000000000000000000000000000..98a77808fdc44245ac9ff92191a20f204da838f0 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionVersionHistoryTest.php @@ -0,0 +1,307 @@ +<?php + +namespace Drupal\FunctionalTests\Entity; + +use Drupal\entity_test\Entity\EntityTestRev; +use Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests version history page. + * + * @group Entity + * @coversDefaultClass \Drupal\Core\Entity\Controller\VersionHistoryController + */ +class RevisionVersionHistoryTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'entity_test', + 'entity_test_revlog', + 'user', + ]; + + /** + * Test all revisions appear, in order of revision creation. + */ + public function testOrder(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + // Need label to be able to assert order. + $entity->setName('view all revisions'); + $user = $this->drupalCreateUser([], 'first revision'); + $entity->setRevisionUser($user); + $entity->setNewRevision(); + $entity->save(); + + $entity->setNewRevision(); + $user = $this->drupalCreateUser([], 'second revision'); + $entity->setRevisionUser($user); + $entity->save(); + + $entity->setNewRevision(); + $user = $this->drupalCreateUser([], 'third revision'); + $entity->setRevisionUser($user); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 3); + // Order is newest to oldest revision by creation order. + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'third revision'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(2)', 'second revision'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(3)', 'first revision'); + } + + /** + * Test current revision is indicated. + * + * @covers \Drupal\Core\Entity\Controller\RevisionControllerTrait::revisionOverview + */ + public function testCurrentRevision(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(['type' => 'entity_test_rev']); + // Need label to be able to assert order. + $entity->setName('view all revisions'); + $entity->setNewRevision(); + $entity->save(); + + $entity->setNewRevision(); + $entity->save(); + + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 3); + // Current revision text is found on the latest revision row. + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision'); + $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(2)', 'Current revision'); + $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(3)', 'Current revision'); + // Current revision row has 'revision-current' class. + $this->assertSession()->elementAttributeContains('css', 'table tbody tr:nth-child(1)', 'class', 'revision-current'); + } + + /** + * Test description with entity implementing revision log. + * + * @covers ::getRevisionDescription + */ + public function testDescriptionRevLog(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + $entity->setName('view all revisions'); + $user = $this->drupalCreateUser([], $this->randomMachineName()); + $entity->setRevisionUser($user); + $entity->setRevisionCreationTime((new \DateTime('2 February 2013 4:00:00pm'))->getTimestamp()); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '02/02/2013 - 16:00'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', $user->getAccountName()); + } + + /** + * Test description with entity, without revision log, no label access. + * + * @covers ::getRevisionDescription + */ + public function testDescriptionNoRevLogNoLabelAccess(): void { + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(['type' => 'entity_test_rev']); + $entity->setName('view all revisions'); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '- Restricted access -'); + $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(1)', $entity->getName()); + } + + /** + * Test description with entity, without revision log, with label access. + * + * @covers ::getRevisionDescription + */ + public function testDescriptionNoRevLogWithLabelAccess(): void { + // Permission grants 'view label' access. + $this->drupalLogin($this->createUser(['view test entity'])); + + /** @var \Drupal\entity_test\Entity\EntityTestRev $entity */ + $entity = EntityTestRev::create(['type' => 'entity_test_rev']); + $entity->setName('view all revisions'); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', $entity->getName()); + $this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(1)', '- Restricted access -'); + } + + /** + * Test revision link, without access to revision page. + * + * @covers ::getRevisionDescription + */ + public function testDescriptionLinkNoAccess(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + $entity->setName('view all revisions'); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 1); + $this->assertSession()->elementsCount('css', 'table tbody tr a', 0); + } + + /** + * Test revision link, with access to revision page. + * + * Test two revisions. Usually the latest revision only checks canonical + * route access, whereas all others will check individual revision access. + * + * @covers ::getRevisionDescription + */ + public function testDescriptionLinkWithAccess(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + // Revision has access to individual revision. + $entity->setName('view all revisions, view revision'); + $entity->save(); + $firstRevisionId = $entity->getRevisionId(); + + // Revision has access to canonical route. + $entity->setName('view all revisions, view'); + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $row1Link = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1) a'); + $this->assertEquals($entity->toUrl()->toString(), $row1Link->getAttribute('href')); + // Reload revision so object has the properties to build a revision link. + $firstRevision = \Drupal::entityTypeManager()->getStorage('entity_test_revlog') + ->loadRevision($firstRevisionId); + $row2Link = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2) a'); + $this->assertEquals($firstRevision->toUrl('revision')->toString(), $row2Link->getAttribute('href')); + } + + /** + * Test revision log message if supported, and HTML tags are stripped. + * + * @covers ::getRevisionDescription + */ + public function testDescriptionRevisionLogMessage(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + $entity->setName('view all revisions'); + $entity->setRevisionLogMessage('<em>Hello</em> <script>world</script> <strong>123</strong>'); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + // Script tags are stripped, while admin-safe tags are retained. + $this->assertSession()->elementContains('css', 'table tbody tr:nth-child(1)', '<em>Hello</em> world <strong>123</strong>'); + } + + /** + * Test revert operation. + * + * @covers ::buildRevertRevisionLink + */ + public function testOperationRevertRevision(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + $entity->setName('view all revisions'); + $entity->save(); + + $entity->setName('view all revisions, revert'); + $entity->setNewRevision(); + $entity->save(); + + $entity->setName('view all revisions, revert'); + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 3); + + // Latest revision does not have revert revision operation: reverting latest + // revision is not permitted. + $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision'); + $this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row1); + + // Revision 2 has revert revision operation: granted access. + $row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)'); + $this->assertSession()->elementExists('named', ['link', 'Revert'], $row2); + + // Revision 3 does not have revert revision operation: no access. + $row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)'); + $this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row3); + + // Reverting latest is allowed if entity access permits it. + $entity->setName('view all revisions, revert, force allow revert'); + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 4); + + $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision'); + $this->assertSession()->elementExists('named', ['link', 'Revert'], $row1); + } + + /** + * Test delete operation. + * + * @covers ::buildDeleteRevisionLink + */ + public function testOperationDeleteRevision(): void { + /** @var \Drupal\entity_test_revlog\Entity\EntityTestWithRevisionLog $entity */ + $entity = EntityTestWithRevisionLog::create(['type' => 'entity_test_revlog']); + $entity->setName('view all revisions'); + $entity->save(); + + $entity->setName('view all revisions, delete revision'); + $entity->setNewRevision(); + $entity->save(); + + $entity->setName('view all revisions, delete revision'); + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 3); + + // Latest revision does not have delete revision operation: deleting latest + // revision is not permitted. + $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision'); + $this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row1); + + // Revision 2 has delete revision operation: granted access. + $row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)'); + $this->assertSession()->elementExists('named', ['link', 'Delete'], $row2); + + // Revision 3 does not have delete revision operation: no access. + $row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)'); + $this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row3); + + // Deleting latest is allowed if entity access permits it. + $entity->setName('view all revisions, delete revision, force allow delete revision'); + $entity->setNewRevision(); + $entity->save(); + + $this->drupalGet($entity->toUrl('version-history')); + $this->assertSession()->elementsCount('css', 'table tbody tr', 4); + + $row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)'); + $this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision'); + $this->assertSession()->elementExists('named', ['link', 'Delete'], $row1); + } + +} diff --git a/core/tests/Drupal/FunctionalTests/Entity/RevisionViewTest.php b/core/tests/Drupal/FunctionalTests/Entity/RevisionViewTest.php new file mode 100644 index 0000000000000000000000000000000000000000..52b34cdacb3bb3d0fff4c1ddb173105566420ed3 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Entity/RevisionViewTest.php @@ -0,0 +1,115 @@ +<?php + +namespace Drupal\FunctionalTests\Entity; + +use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; +use Drupal\Tests\BrowserTestBase; + +/** + * Tests revision view page. + * + * @group Entity + * @coversDefaultClass \Drupal\Core\Entity\Controller\EntityRevisionViewController + */ +class RevisionViewTest extends BrowserTestBase { + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'block', + 'entity_test', + 'entity_test_revlog', + 'field', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->drupalPlaceBlock('page_title_block'); + } + + /** + * Tests revision page. + * + * @param string $entityTypeId + * Entity type to test. + * @param string $expectedPageTitle + * Expected page title. + * + * @covers ::__invoke + * + * @dataProvider providerRevisionPage + */ + public function testRevisionPage(string $entityTypeId, string $expectedPageTitle): void { + $storage = \Drupal::entityTypeManager()->getStorage($entityTypeId); + + // Add a field to test revision page output. + $fieldStorage = FieldStorageConfig::create([ + 'entity_type' => $entityTypeId, + 'field_name' => 'foo', + 'type' => 'string', + ]); + $fieldStorage->save(); + FieldConfig::create([ + 'field_storage' => $fieldStorage, + 'bundle' => $entityTypeId, + ])->save(); + + /** @var \Drupal\Core\Entity\EntityDisplayRepositoryInterface $displayRepository */ + $displayRepository = \Drupal::service('entity_display.repository'); + $displayRepository->getViewDisplay($entityTypeId, $entityTypeId) + ->setComponent('foo', [ + 'type' => 'string', + ]) + ->save(); + + $entity = $storage->create(['type' => $entityTypeId]); + $entity->setName('revision 1, view revision'); + $revision1Body = $this->randomMachineName(); + $entity->foo = $revision1Body; + $entity->setNewRevision(); + if ($entity instanceof RevisionLogInterface) { + $date = new \DateTime('11 January 2009 4:00:00pm'); + $entity->setRevisionCreationTime($date->getTimestamp()); + } + $entity->save(); + $revisionId = $entity->getRevisionId(); + + $entity->setName('revision 2, view revision'); + $revision2Body = $this->randomMachineName(); + $entity->foo = $revision2Body; + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionCreationTime($date->modify('+1 hour')->getTimestamp()); + } + $entity->setNewRevision(); + $entity->save(); + + $revision = $storage->loadRevision($revisionId); + $this->drupalGet($revision->toUrl('revision')); + + $this->assertSession()->pageTextContains($expectedPageTitle); + $this->assertSession()->pageTextContains($revision1Body); + $this->assertSession()->pageTextNotContains($revision2Body); + } + + /** + * Data provider for testRevisionPage. + */ + public function providerRevisionPage(): array { + return [ + ['entity_test_rev', 'Revision of revision 1, view revision'], + ['entity_test_revlog', 'Revision of revision 1, view revision from Sun, 01/11/2009 - 16:00'], + ]; + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php index a394660485d0285118a4fed2d3b21e5e30c38eb4..7218e99135c5a99adbe077ebfc64ae1eab4749d3 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityValidationTest.php @@ -164,10 +164,10 @@ protected function checkValidation($entity_type) { $this->assertEquals('This value should not be null.', $violations[0]->getMessage()); $test_entity = clone $entity; - $test_entity->name->value = $this->randomString(33); + $test_entity->name->value = $this->randomString(65); $violations = $test_entity->validate(); $this->assertEquals(1, $violations->count(), 'Validation failed.'); - $this->assertEquals(t('%name: may not be longer than @max characters.', ['%name' => 'Name', '@max' => 32]), $violations[0]->getMessage()); + $this->assertEquals(t('%name: may not be longer than @max characters.', ['%name' => 'Name', '@max' => 64]), $violations[0]->getMessage()); // Make sure the information provided by a violation is correct. $violation = $violations[0]; diff --git a/core/tests/Drupal/KernelTests/Core/Entity/RevisionRouteProviderTest.php b/core/tests/Drupal/KernelTests/Core/Entity/RevisionRouteProviderTest.php new file mode 100644 index 0000000000000000000000000000000000000000..bdde7ca46adb2072d834cbfaa46ed5a695c888ff --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/RevisionRouteProviderTest.php @@ -0,0 +1,127 @@ +<?php + +namespace Drupal\KernelTests\Core\Entity; + +use Drupal\entity_test\Entity\EntityTestRev; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; + +/** + * Tests revision route provider. + * + * @coversDefaultClass \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider + * @group Entity + */ +class RevisionRouteProviderTest extends KernelTestBase { + + use UserCreationTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = ['entity_test', 'user']; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + $this->installEntitySchema('entity_test_rev'); + $this->installEntitySchema('user'); + $this->setUpCurrentUser(['uid' => 1]); + } + + /** + * Tests revision access for revision overview. + * + * Tests routes which do not need a specific revision parameter. + */ + public function testOperationAccessOverview(): void { + $entity = EntityTestRev::create() + ->setName('first revision'); + $entity->save(); + $this->assertFalse($entity->toUrl('version-history')->access()); + + $entity + ->setName('view all revisions') + ->setNewRevision(); + $entity->save(); + $this->assertTrue($entity->toUrl('version-history')->access()); + } + + /** + * Tests revision access is granted by entity operations. + * + * Ensures entity is sourced from revision parameter, not entity parameter or + * default revision. + * E.g 'entity_test_rev_revision' + * in '/{entity_test_rev}/revision/{entity_test_rev_revision}/view'. + * + * @param string $linkTemplate + * The link template to test. + * @param string $entityLabel + * Access is granted via specially named entity label passed to + * EntityTestAccessControlHandler. + * + * @dataProvider providerOperationAccessRevisionRoutes + */ + public function testOperationAccessRevisionRoutes(string $linkTemplate, string $entityLabel): void { + $entityStorage = \Drupal::entityTypeManager()->getStorage('entity_test_rev'); + + $entity = EntityTestRev::create() + ->setName('first revision'); + $entity->save(); + $noAccessRevisionId = $entity->getRevisionId(); + + $entity + ->setName($entityLabel) + ->setNewRevision(); + $entity->save(); + $hasAccessRevisionId = $entity->getRevisionId(); + + $this->assertNotEquals($noAccessRevisionId, $hasAccessRevisionId); + + // Create an additional default revision to ensure access isn't being pulled + // from default revision. + $entity + ->setName('default') + ->setNewRevision(); + $entity->isDefaultRevision(TRUE); + $entity->save(); + + // Reload entity so default revision flags are accurate. + $originalRevision = $entityStorage->loadRevision($noAccessRevisionId); + $viewableRevision = $entityStorage->loadRevision($hasAccessRevisionId); + + $this->assertFalse($originalRevision->toUrl($linkTemplate)->access()); + $this->assertTrue($viewableRevision->toUrl($linkTemplate)->access()); + } + + /** + * Data provider for testOperationAccessRevisionRoutes. + * + * @return array + * Data for testing. + */ + public function providerOperationAccessRevisionRoutes(): array { + $data = []; + + $data['view revision'] = [ + 'revision', + 'view revision', + ]; + + $data['revert revision'] = [ + 'revision-revert-form', + 'revert', + ]; + + $data['delete revision'] = [ + 'revision-delete-form', + 'delete revision', + ]; + + return $data; + } + +}