diff --git a/core/core.services.yml b/core/core.services.yml index c42ca2dd21..dd6a05b6c2 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -1178,6 +1178,11 @@ services: arguments: ['@entity_type.manager', '@tempstore.private', '@request_stack'] tags: - { name: access_check, applies_to: _entity_delete_multiple_access } + access_checker.entity_revision: + class: \Drupal\Core\Entity\EntityRevisionAccessCheck + arguments: ['@entity_type.manager', '@current_route_match'] + tags: + - { name: access_check, applies_to: _entity_access_revision } access_check.theme: class: Drupal\Core\Theme\ThemeAccessCheck arguments: ['@theme_handler'] diff --git a/core/lib/Drupal/Core/Entity/Controller/RevisionControllerTrait.php b/core/lib/Drupal/Core/Entity/Controller/RevisionControllerTrait.php new file mode 100644 index 0000000000..c7f8967044 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Controller/RevisionControllerTrait.php @@ -0,0 +1,172 @@ +getEntityType(); + $result = $this->entityTypeManager()->getStorage($entity_type->id())->getQuery() + ->allRevisions() + ->condition($entity_type->getKey('id'), $entity->id()) + ->sort($entity_type->getKey('revision'), 'DESC') + ->execute(); + return array_keys($result); + } + + /** + * 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 { + $currentLangcode = $this->languageManager() + ->getCurrentLanguage(LanguageInterface::TYPE_CONTENT) + ->getId(); + + $entityStorage = $this->entityTypeManager()->getStorage($entity->getEntityTypeId()); + assert($entityStorage instanceof RevisionableStorageInterface); + + $rows = []; + foreach ($entityStorage->loadMultipleRevisions($this->revisionIds($entity)) as $revision) { + $row = []; + + // Only show revisions that are affected by the language that is being + // displayed. + if ($revision->hasTranslation($currentLangcode) && $revision->getTranslation($currentLangcode)->isRevisionTranslationAffected()) { + $row[] = $this->getRevisionDescription($revision); + + if ($revision->isDefaultRevision()) { + $row[] = [ + 'data' => [ + '#prefix' => '', + '#markup' => $this->t('Current revision'), + '#suffix' => '', + ], + ]; + foreach ($row as &$current) { + $current['class'] = ['revision-current']; + } + } + else { + $links = $this->getOperationLinks($revision); + $row[] = [ + 'data' => [ + '#type' => 'operations', + '#links' => $links, + ], + ]; + } + } + + $rows[] = $row; + } + + $build['entity_revisions_table'] = [ + '#theme' => 'table', + '#header' => [ + $this->t('Revision'), + $this->t('Operations'), + ], + '#rows' => $rows, + ]; + + (new CacheableMetadata()) + // Only dealing with this entity and no external dependencies. + ->addCacheableDependency($entity) + ->addCacheContexts(['languages:language_content']) + ->applyTo($build); + + return $build; + } + + /** + * 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/Controller/VersionHistoryController.php b/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php new file mode 100644 index 0000000000..5c3c8949d5 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Controller/VersionHistoryController.php @@ -0,0 +1,158 @@ +entityTypeManager = $entityTypeManager; + $this->languageManager = $languageManager; + $this->dateFormatter = $dateFormatter; + $this->renderer = $renderer; + } + + /** + * {@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 older revisions of an entity. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $routeMatch + * The route match. + * + * @return array + * A render array. + */ + public function versionHistory(RouteMatchInterface $routeMatch): array { + $entityTypeId = $routeMatch->getRouteObject()->getOption('entity_type_id'); + $entity = $routeMatch->getParameter($entityTypeId); + return $this->revisionOverview($entity); + } + + /** + * {@inheritdoc} + */ + protected function buildRevertRevisionLink(RevisionableInterface $revision): ?array { + if (!$revision->hasLinkTemplate('revision-revert-form')) { + return NULL; + } + + $url = $revision->toUrl('revision-revert-form'); + return [ + 'title' => $this->t('Revert'), + 'url' => $revision->toUrl('revision-revert-form'), + 'access' => $url->access(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function buildDeleteRevisionLink(EntityInterface $revision): ?array { + // @todo Delete form doesnt exist yet. + if (!$revision->hasLinkTemplate('revision-delete-form')) { + return NULL; + } + + $url = $revision->toUrl('revision-delete-form'); + return [ + 'title' => $this->t('Delete'), + 'url' => $revision->toUrl('revision-delete-form'), + 'access' => $url->access(), + ]; + } + + /** + * {@inheritdoc} + */ + protected function getRevisionDescription(RevisionableInterface $revision): array { + $context = []; + if ($revision instanceof RevisionLogInterface) { + // Use revision link to link to revisions that are not active. + $linkText = $this->dateFormatter->format($revision->getRevisionCreationTime(), 'short'); + + // @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->label(); + } + + $context['date'] = $revision->toLink($linkText, 'revision')->toString(); + $context['message'] = $revision instanceof RevisionLogInterface ? [ + '#markup' => $revision->getRevisionLogMessage(), + '#allowed_tags' => Xss::getHtmlTagList(), + ] : ''; + + return [ + 'data' => [ + '#type' => 'inline_template', + '#template' => isset($context['username']) + ? '{% trans %}{{ date }} by {{ username }}{% endtrans %}{% if message %}
{{ message }}
{% endif %}' + : '{% trans %} {{ date }} {% endtrans %}{% if message %}{{ message }}
{% endif %}', + '#context' => $context, + ], + ]; + } + +} + diff --git a/core/lib/Drupal/Core/Entity/EntityBase.php b/core/lib/Drupal/Core/Entity/EntityBase.php index 6528f4615e..c37da5fb7d 100644 --- a/core/lib/Drupal/Core/Entity/EntityBase.php +++ b/core/lib/Drupal/Core/Entity/EntityBase.php @@ -271,7 +271,7 @@ abstract class EntityBase implements EntityInterface { $parameter_name = $this->getEntityType()->getBundleEntityType() ?: $this->getEntityType()->getKey('bundle'); $uri_route_parameters[$parameter_name] = $this->bundle(); } - if ($rel === 'revision' && $this instanceof RevisionableInterface) { + if ($this instanceof RevisionableInterface && strpos($rel, 'revision') === 0) { $uri_route_parameters[$this->getEntityTypeId() . '_revision'] = $this->getRevisionId(); } diff --git a/core/lib/Drupal/Core/Entity/EntityRevisionAccessCheck.php b/core/lib/Drupal/Core/Entity/EntityRevisionAccessCheck.php new file mode 100644 index 0000000000..0dc438436b --- /dev/null +++ b/core/lib/Drupal/Core/Entity/EntityRevisionAccessCheck.php @@ -0,0 +1,144 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * Checks routing access for an entity revision. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Session\AccountInterface $account + * The currently logged in account. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The current route match. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(Route $route, AccountInterface $account, RouteMatchInterface $route_match = NULL): AccessResultInterface { + $operation = $route->getRequirement('_entity_access_revision'); + [$entity_type_id, $operation] = explode('.', $operation, 2); + + if ($operation === 'list') { + $entity = $route_match->getParameter($entity_type_id); + return AccessResult::allowedIf($this->checkAccess($entity, $account, $operation))->cachePerPermissions(); + } + else { + $entity_revision = $route_match->getParameter($entity_type_id . '_revision'); + return AccessResult::allowedIf($entity_revision && $this->checkAccess($entity_revision, $account, $operation))->cachePerPermissions(); + } + } + + /** + * Checks entity revision access. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * An entity revision. + * @param \Drupal\Core\Session\AccountInterface $account + * A user object representing the user for whom the operation is to be + * performed. + * @param string $operation + * The specific operation being checked. Defaults to 'view'. + * + * @return bool + * Whether the operation may be performed. + */ + protected function checkAccess(RevisionableInterface $revision, AccountInterface $account, $operation = 'view'): bool { + $entity_type = $revision->getEntityType(); + $entity_type_id = $revision->getEntityTypeId(); + $entity_access = $this->entityTypeManager->getAccessControlHandler($entity_type_id); + + $entity_storage = $this->entityTypeManager->getStorage($entity_type_id); + assert($entity_storage instanceof RevisionableStorageInterface); + + $map = [ + 'view' => "view all $entity_type_id revisions", + 'list' => "view all $entity_type_id revisions", + 'update' => "revert all $entity_type_id revisions", + 'delete' => "delete all $entity_type_id revisions", + ]; + $bundle = $revision->bundle(); + $type_map = [ + 'view' => "view $entity_type_id $bundle revisions", + 'list' => "view $entity_type_id $bundle revisions", + 'update' => "revert $entity_type_id $bundle revisions", + 'delete' => "delete $entity_type_id $bundle revisions", + ]; + + if (!$revision || !isset($map[$operation]) || !isset($type_map[$operation])) { + // If there was no node to check against, or the $op was not one of the + // supported ones, we return access denied. + return FALSE; + } + + // Statically cache access by revision ID, language code, user account ID, + // and operation. + $langcode = $revision->language()->getId(); + $cid = $revision->getRevisionId() . ':' . $langcode . ':' . $account->id() . ':' . $operation; + + if (!isset($this->accessCache[$cid])) { + $admin_permission = $entity_type->getAdminPermission(); + + // Perform basic permission checks first. + if (!$account->hasPermission($map[$operation]) && !$account->hasPermission($type_map[$operation]) && ($admin_permission && !$account->hasPermission($admin_permission))) { + $this->accessCache[$cid] = FALSE; + return FALSE; + } + + if (($admin_permission = $entity_type->getAdminPermission()) && $account->hasPermission($admin_permission)) { + $this->accessCache[$cid] = TRUE; + } + else { + // Entity access handlers are generally not aware of the "list" + // operation. + $operation = $operation == 'list' ? 'view' : $operation; + // First check the access to the default revision and finally, if the + // node passed in is not the default revision then access to that, too. + $this->accessCache[$cid] = $entity_access->access($entity_storage->load($revision->id()), $operation, $account) && ($revision->isDefaultRevision() || $entity_access->access($revision, $operation, $account)); + } + } + + return $this->accessCache[$cid]; + } + +} 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 0000000000..41637879d5 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Form/RevisionRevertForm.php @@ -0,0 +1,300 @@ +dateFormatter = $dateFormatter; + $this->bundleInformation = $bundleInformation; + $this->messenger = $messenger; + $this->time = $time; + } + + /** + * {@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') + ); + } + + /** + * {@inheritdoc} + */ + public function getBaseFormId() { + return $this->revision->getEntityTypeId() . '_revision_revert'; + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return $this->revision->getEntityTypeId() . '_revision_revert'; + } + + /** + * {@inheritdoc} + */ + public function getQuestion() { + if ($this->getEntity() instanceof RevisionLogInterface) { + return $this->t('Are you sure you want to revert to the revision from %revision-date?', ['%revision-date' => $this->dateFormatter->format($this->getEntity()->getRevisionCreationTime())]); + } + return $this->t('Are you sure you want to revert the revision?'); + } + + /** + * {@inheritdoc} + */ + public function getCancelUrl() { + if ($this->getEntity()->getEntityType()->hasLinkTemplate('version-history')) { + return $this->getEntity()->toUrl('version-history'); + } + return $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) { + $this->revision = $this->prepareRevision($this->revision); + $bundleLabel = $this->getBundleLabel($this->revision); + $revisionLabel = $this->revision->label(); + if ($this->revision instanceof RevisionLogInterface) { + // The revision timestamp will be updated when the revision is saved. Keep + // the original one for the confirmation message. + $originalRevisionTimestamp = $this->revision->getRevisionCreationTime(); + + $date = $this->dateFormatter->format($originalRevisionTimestamp); + $this->revision->setRevisionLogMessage($this->t('Copy of the revision from %date.', ['%date' => $date])); + $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' => $this->revision->getRevisionId(), + ]); + + $form_state->setRedirectUrl($this->revision->toUrl('version-history')); + } + + /** + * Prepares a revision to be reverted. + * + * @param \Drupal\Core\Entity\RevisionableInterface $revision + * The revision to be reverted. + * + * @return \Drupal\Core\Entity\RevisionableInterface + * The prepared revision ready to be stored. + */ + protected function prepareRevision(RevisionableInterface $revision): RevisionableInterface { + $revision->setNewRevision(); + $revision->isDefaultRevision(TRUE); + if ($this->revision instanceof EntityChangedInterface) { + $this->revision->setChangedTime($this->time->getRequestTime()); + } + return $revision; + } + + /** + * Returns the bundle label of an entity. + * + * @param \Drupal\Core\Entity\RevisionableInterface $entity + * The entity. + * + * @return string + * The bundle label. + */ + protected function getBundleLabel(RevisionableInterface $entity): string { + $bundle_info = $this->bundleInformation->getBundleInfo($entity->getEntityTypeId()); + return $bundle_info[$entity->bundle()]['label']; + } + + /** + * {@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) { + $this->revision = $entity; + } + + /** + * {@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) { + $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; + } + +} 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 0000000000..8c456c9e31 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Plugin/Derivative/VersionHistoryLocalTasks.php @@ -0,0 +1,68 @@ +entityTypeManager = $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) { + // @todo remove node workaround, either switching over Node to use this + // deriver and all associated revision routes, or require all entities to + // specify their own tasks, just as they already have to opt into + // revision templates and form classes. + if ($entity_type_id === 'node') { + continue; + } + + 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 0000000000..ea220483ec --- /dev/null +++ b/core/lib/Drupal/Core/Entity/Routing/RevisionHtmlRouteProvider.php @@ -0,0 +1,137 @@ +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); + } + + 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 . '::versionHistory', + '_title' => 'Revisions', + ]) + ->setRequirement('_entity_access_revision', "$entityTypeId.list") + ->setOption('entity_type_id', $entityTypeId) + ->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(); + return (new Route($entityType->getLinkTemplate('revision'))) + ->addDefaults([ + '_controller' => EntityViewController::class . '::viewRevision', + '_title_callback' => EntityController::class . '::title', + ]) + ->addRequirements([ + '_entity_access_revision' => "$entityTypeId.view", + ]) + ->setOption('parameters', [ + $entityTypeId => [ + 'type' => 'entity:' . $entityTypeId, + ], + $entityTypeId . '_revision' => [ + '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(); + return (new Route($entityType->getLinkTemplate('revision-revert-form'))) + ->addDefaults([ + '_entity_form' => $entityTypeId . '.revision-revert', + '_title' => 'Revert revision', + ]) + ->addRequirements([ + '_entity_access_revision' => "$entityTypeId.update", + ]) + ->setOption('parameters', [ + $entityTypeId => [ + 'type' => 'entity:' . $entityTypeId, + ], + $entityTypeId . '_revision' => [ + 'type' => 'entity_revision:' . $entityTypeId, + ], + ]); + } + +} diff --git a/core/modules/system/system.links.task.yml b/core/modules/system/system.links.task.yml index db8f8564d8..ed66b0595e 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.permissions.yml b/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml index abfb616fc2..debe3bb320 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml +++ b/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml @@ -14,6 +14,9 @@ administer entity_test_bundle content: title: 'administer entity_test_bundle content' view all entity_test_query_access entities: title: 'view all entity_test_query_access entities' +'view all entity_test_rev revisions': + title: 'View all entity_test_rev revisions' + 'restrict access': TRUE permission_callbacks: - \Drupal\entity_test\EntityTestPermissions::entityTestBundlePermissions 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 f51ce88058..8bbe69cdcb 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 @@ -23,6 +23,7 @@ use Drupal\Core\Field\BaseFieldDefinition; * "views_data" = "Drupal\views\EntityViewsData", * "route_provider" = { * "html" = "Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider", + * "revision" = "Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider", * }, * }, * base_table = "entity_test_rev", @@ -44,6 +45,8 @@ use Drupal\Core\Field\BaseFieldDefinition; * "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-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/src/Unit/Menu/SystemLocalTasksTest.php b/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php index 9c59b4c48c..04c1940674 100644 --- a/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php +++ b/core/modules/system/tests/src/Unit/Menu/SystemLocalTasksTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\system\Unit\Menu; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Extension\Extension; use Drupal\Tests\Core\Menu\LocalTaskIntegrationTestBase; @@ -15,7 +16,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 +45,12 @@ class SystemLocalTasksTest extends LocalTaskIntegrationTestBase { ->with('bartik') ->willReturn(TRUE); $this->container->set('theme_handler', $this->themeHandler); + + $entityTypeManager = $this->createMock(EntityTypeManagerInterface::class); + $entityTypeManager->expects($this->any()) + ->method('getDefinitions') + ->willReturn([]); + $this->container->set('entity_type.manager', $entityTypeManager); } /** diff --git a/core/tests/Drupal/FunctionalTests/Routing/RevisionRouteAccessTest.php b/core/tests/Drupal/FunctionalTests/Routing/RevisionRouteAccessTest.php new file mode 100644 index 0000000000..e3f1df105a --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Routing/RevisionRouteAccessTest.php @@ -0,0 +1,78 @@ +placeBlock('local_tasks_block'); + $this->placeBlock('system_breadcrumb_block'); + + $this->drupalLogin($this->drupalCreateUser([ + 'administer entity_test content', + 'view test entity', + 'view all entity_test_rev revisions', + ])); + } + + /** + * Test enhanced entity revision routes access. + */ + public function testRevisionRouteAccess() { + $entity = EntityTestRev::create([ + 'name' => 'rev 1', + 'type' => 'default', + ]); + $entity->save(); + + $revision = clone $entity; + $revision->name->value = 'rev 2'; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(FALSE); + $revision->save(); + + $this->drupalGet('/entity_test_rev/1/revisions'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->responseContains('Revisions'); + $edit_link = $this->getSession()->getPage()->findLink('Edit'); + $edit_link->click(); + $this->assertSession()->addressEquals('/entity_test_rev/manage/1/edit'); + // Check if we have revision tab link on edit page. + $this->getSession()->getPage()->findLink('Revisions')->click(); + $this->assertSession()->addressEquals('/entity_test_rev/1/revisions'); + $this->drupalGet('/entity_test_rev/1/revision/2/view'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->responseContains('rev 2'); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/RevisionBasicUITest.php b/core/tests/Drupal/KernelTests/Core/Entity/RevisionBasicUITest.php new file mode 100644 index 0000000000..7c12a57574 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/RevisionBasicUITest.php @@ -0,0 +1,188 @@ +installSchema('system', ['sequences']); + $this->installEntitySchema('user'); + $this->installEntitySchema('entity_test_rev'); + + \Drupal::service('router.builder')->rebuild(); + + // Required to fix https://www.drupal.org/project/drupal/issues/3056234. + User::create([ + 'name' => '', + 'uid' => 0, + ])->save(); + } + + /** + * Tests the revision history controller. + */ + public function testRevisionHistory() { + $entity = EntityTestRev::create([ + 'name' => 'rev 1', + 'type' => 'default', + ]); + $entity->save(); + + $revision = clone $entity; + $revision->name->value = 'rev 2'; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(FALSE); + $revision->save(); + + /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel */ + $http_kernel = \Drupal::service('http_kernel'); + $request = Request::create($revision->toUrl('version-history')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(403, $response->getStatusCode()); + $role_admin = Role::create(['id' => 'test_role_admin']); + $role_admin->grantPermission('administer entity_test content'); + $role_admin->save(); + + $role = Role::create(['id' => 'test_role']); + $role->grantPermission('view all entity_test_rev revisions'); + $role->grantPermission('administer entity_test content'); + $role->save(); + + $user_admin = $this->createUser([], 'Test user admin'); + $user_admin->addRole($role_admin->id()); + \Drupal::service('account_switcher')->switchTo($user_admin); + $request = Request::create($revision->toUrl('version-history')->toString()); + $response = $http_kernel->handle($request); + + $this->assertEquals(200, $response->getStatusCode()); + + $user = $this->createUser([], 'Test user'); + $user->addRole($role->id()); + \Drupal::service('account_switcher')->switchTo($user); + + $request = Request::create($revision->toUrl('version-history')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(200, $response->getStatusCode()); + + // This ensures that the default revision is still the first revision. + $this->assertTrue(strpos($response->getContent(), 'entity_test_rev/1/revision/2/view') !== FALSE); + $this->assertTrue(strpos($response->getContent(), 'entity_test_rev/1') !== FALSE); + + // Publish a new revision. + $revision = clone $entity; + $revision->name->value = 'rev 3'; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(TRUE); + $revision->save(); + + $request = Request::create($revision->toUrl('version-history')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(200, $response->getStatusCode()); + + // The first revision row should now include a revert link. + $this->assertTrue(strpos($response->getContent(), 'entity_test_rev/1/revision/1/revert') !== FALSE); + } + + public function _testRevisionView() { + $entity = EntityTestRev::create([ + 'name' => 'rev 1', + 'type' => 'default', + ]); + $entity->save(); + + $revision = clone $entity; + $revision->name->value = 'rev 2'; + $revision->setNewRevision(TRUE); + $revision->isDefaultRevision(FALSE); + $revision->save(); + + /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel */ + $http_kernel = \Drupal::service('http_kernel'); + $request = Request::create($revision->toUrl('revision')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(403, $response->getStatusCode()); + + $role_admin = Role::create(['id' => 'test_role_admin']); + $role_admin->grantPermission('administer entity_test content'); + $role_admin->save(); + + $role = Role::create(['id' => 'test_role']); + $role->grantPermission('view all entity_test_rev revisions'); + $role->grantPermission('administer entity_test content'); + $role->save(); + + $user_admin = User::create([ + 'name' => 'Test user admin', + ]); + $user_admin->addRole($role_admin->id()); + \Drupal::service('account_switcher')->switchTo($user_admin); + + $request = Request::create($revision->toUrl('version-history')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(200, $response->getStatusCode()); + + $user = User::create([ + 'name' => 'Test user', + ]); + $user->addRole($role->id()); + \Drupal::service('account_switcher')->switchTo($user); + + $request = Request::create($revision->toUrl('revision')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(200, $response->getStatusCode()); + $this->assertNotContains('rev 1', $response->getContent()); + $this->assertContains('rev 2', $response->getContent()); + } + + public function _testRevisionRevert() { + $entity = EntityTestRev::create([ + 'name' => 'rev 1', + 'type' => 'entity_test_enhance', + ]); + $entity->save(); + $entity->name->value = 'rev 2'; + $entity->setNewRevision(TRUE); + $entity->isDefaultRevision(TRUE); + $entity->save(); + + $role = Role::create(['id' => 'test_role']); + $role->grantPermission('administer entity_test content'); + $role->grantPermission('revert all entity_test_rev revisions'); + $role->save(); + + $user = User::create([ + 'name' => 'Test user', + ]); + $user->addRole($role->id()); + \Drupal::service('account_switcher')->switchTo($user); + + /** @var \Symfony\Component\HttpKernel\HttpKernelInterface $http_kernel */ + $http_kernel = \Drupal::service('http_kernel'); + $request = Request::create($entity->toUrl('revision-revert-form')->toString()); + $response = $http_kernel->handle($request); + $this->assertEquals(200, $response->getStatusCode()); + } + +} diff --git a/core/tests/Drupal/KernelTests/Core/Entity/RevisionOverviewIntegrationTest.php b/core/tests/Drupal/KernelTests/Core/Entity/RevisionOverviewIntegrationTest.php new file mode 100644 index 0000000000..a2451941cf --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Entity/RevisionOverviewIntegrationTest.php @@ -0,0 +1,52 @@ +rebuild(); + } + + public function testIntegration() { + /** @var \Drupal\Core\Menu\LocalTaskManagerInterface $local_tasks_manager */ + $local_tasks_manager = \Drupal::service('plugin.manager.menu.local_task'); + + $tasks = $local_tasks_manager->getDefinitions(); + $this->assertArrayHasKey('entity.version_history:entity_test_rev.version_history', $tasks); + $this->assertArrayNotHasKey('entity.revisions_overview:node', $tasks, 'Node should have been excluded because it provides their own'); + + $task = $tasks['entity.version_history:entity_test_rev.version_history']; + $this->assertEquals('entity.entity_test_rev.version_history', $task['route_name']); + $this->assertEquals('entity.entity_test_rev.canonical', $task['base_route']); + + /** @var \Drupal\Core\Routing\RouteProviderInterface $route_provider */ + $route_provider = \Drupal::service('router.route_provider'); + + $route = $route_provider->getRouteByName('entity.entity_test_rev.version_history'); + $this->assertInstanceOf(Route::class, $route); + $this->assertEquals('Drupal\Core\Entity\Controller\VersionHistoryController::versionHistory', $route->getDefault('_controller')); + } + +}