diff --git a/entity.links.task.yml b/entity.links.task.yml new file mode 100644 index 0000000000000000000000000000000000000000..8116ae3260ec83c3d5b0eed9df15d359e614ec9d --- /dev/null +++ b/entity.links.task.yml @@ -0,0 +1,3 @@ +entity.revisions_overview: + deriver: 'Drupal\entity\Plugin\Derivative\RevisionsOverviewDeriver' + weight: 100 diff --git a/src/Access/EntityRevisionRouteAccessChecker.php b/src/Access/EntityRevisionRouteAccessChecker.php index 8a4184f6c1c76d49da0b2c6c872671ca88e33476..f4f8e1de0ca41a5cf201c18210b32b657df72f73 100644 --- a/src/Access/EntityRevisionRouteAccessChecker.php +++ b/src/Access/EntityRevisionRouteAccessChecker.php @@ -11,7 +11,6 @@ use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Entity\RevisionableInterface; use Drupal\Core\Routing\Access\AccessInterface; use Drupal\Core\Session\AccountInterface; use Symfony\Component\HttpFoundation\Request; @@ -48,10 +47,17 @@ class EntityRevisionRouteAccessChecker implements AccessInterface { * {@inheritdoc} */ public function access(Route $route, AccountInterface $account, Request $request) { - $_entity_revision = $request->attributes->get('_entity_revision'); $operation = $route->getRequirement('_entity_access_revision'); list(, $operation) = explode('.', $operation, 2); - return AccessResult::allowedIf($_entity_revision && $this->checkAccess($_entity_revision, $account, $operation))->cachePerPermissions(); + + if ($operation === 'list') { + $_entity = $request->attributes->get('_entity', $request->attributes->get($route->getOption('entity_type_id'))); + return AccessResult::allowedIf($this->checkAccess($_entity, $account, $operation))->cachePerPermissions(); + } + else { + $_entity_revision = $request->attributes->get('_entity_revision'); + return AccessResult::allowedIf($_entity_revision && $this->checkAccess($_entity_revision, $account, $operation))->cachePerPermissions(); + } } protected function checkAccess(ContentEntityInterface $entity, AccountInterface $account, $operation = 'view') { @@ -64,12 +70,14 @@ class EntityRevisionRouteAccessChecker implements AccessInterface { $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 = $entity->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", ]; diff --git a/src/Controller/RevisionControllerTrait.php b/src/Controller/RevisionControllerTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..5c3319ccdff5b825bbca976cdb2a77932aa5b0f7 --- /dev/null +++ b/src/Controller/RevisionControllerTrait.php @@ -0,0 +1,199 @@ +<?php + +/** + * @file + * Contains \Drupal\entity\Controller\RevisionControllerTrait. + */ + +namespace Drupal\entity\Controller; + +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Entity\EntityInterface; + +/** + * Defines a trait for common revision UI functionality. + */ +trait RevisionControllerTrait { + + /** + * Returns the entity type manager. + * + * @return \Drupal\Core\Entity\EntityTypeManagerInterface + */ + abstract protected function entityTypeManager(); + + /** + * Returns the langauge manager. + * + * @return \Drupal\Core\Language\LanguageManagerInterface + */ + public abstract function languageManager(); + + /** + * Determines if the user has permission to revert revisions. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check revert access for. + * + * @return bool + * TRUE if the user has revert access. + */ + abstract protected function hasRevertRevisionAccess(EntityInterface $entity); + + /** + * Determines if the user has permission to delete revisions. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check delete revision access for. + * + * @return bool + * TRUE if the user has delete revision access. + */ + abstract protected function hasDeleteRevisionAccess(EntityInterface $entity); + + /** + * Builds a link to revert an entity revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity_revision + * The entity to build a revert revision link for. + * + * @return array + * A link render array. + * + */ + abstract protected function buildRevertRevisionLink(EntityInterface $entity_revision); + + /** + * Builds a link to delete an entity revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity_revision + * The entity to build a delete revision link for. + * + * @return array + * A link render array. + */ + abstract protected function buildDeleteRevisionLink(EntityInterface $entity_revision); + + /** + * Returns a string providing details of the revision. + * + * E.g. Node describes its revisions using {date} by {username}. For the + * non-current revision, it also provides a link to view that revision. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $revision + * The entity revision. + * @param bool $is_current + * TRUE if the revision is the current revision. + * + * @return string + * Returns a string to provide the details of the revision. + */ + abstract protected function getRevisionDescription(ContentEntityInterface $revision, $is_current = FALSE); + + /** + * Loads all revision IDs of an entity sorted by revision ID descending. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * The entity. + * + * @return mixed[] + */ + protected function revisionIds(ContentEntityInterface $entity) { + $entity_type = $entity->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 older revisions of an entity. + * + * @param \Drupal\Core\Entity\ContentEntityInterface $entity + * An entity object. + * + * @return array + * A render array. + */ + protected function revisionOverview(ContentEntityInterface $entity) { + $langcode = $this->languageManager() + ->getCurrentLanguage(LanguageInterface::TYPE_CONTENT) + ->getId(); + $entity_storage = $this->entityTypeManager() + ->getStorage($entity->getEntityTypeId()); + + $header = [$this->t('Revision'), $this->t('Operations')]; + $rows = []; + + $revision_ids = $this->revisionIds($entity); + // @todo Expand the entity storage to load multiple revisions. + $entity_revisions = array_combine($revision_ids, array_map(function($vid) use ($entity_storage) { + return $entity_storage->loadRevision($vid); + }, $revision_ids)); + + foreach ($entity_revisions as $revision) { + $row = []; + /** @var \Drupal\Core\Entity\ContentEntityInterface $revision */ + if ($revision->hasTranslation($langcode) && $revision->getTranslation($langcode) + ->isRevisionTranslationAffected() + ) { + $row[] = $this->getRevisionDescription($revision, $revision->isDefaultRevision()); + + if ($revision->isDefaultRevision()) { + $row[] = [ + 'data' => [ + '#prefix' => '<em>', + '#markup' => $this->t('Current revision'), + '#suffix' => '</em>', + ], + ]; + foreach ($row as &$current) { + $current['class'] = ['revision-current']; + } + } + else { + $row[] = $this->getOperationLinks($revision); + } + } + + $rows[] = $row; + } + + $build[$entity->getEntityTypeId() . '_revisions_table'] = [ + '#theme' => 'table', + '#rows' => $rows, + '#header' => $header, + ]; + + // We have no clue about caching yet. + $build['#cache']['max-age'] = 0; + + return $build; + } + + /** + * Get the links of the operations for an entity revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity_revision + * The entity to build the revision links for. + * + * @return array + * The operation links. + */ + protected function getOperationLinks(EntityInterface $entity_revision) { + $links = []; + if ($this->hasRevertRevisionAccess($entity_revision)) { + $links['revert'] = $this->buildRevertRevisionLink($entity_revision); + } + + if ($this->hasDeleteRevisionAccess($entity_revision)) { + $links['delete'] = $this->buildDeleteRevisionLink($entity_revision); + } + + return array_filter($links); + } + +} diff --git a/src/Controller/RevisionOverviewController.php b/src/Controller/RevisionOverviewController.php new file mode 100644 index 0000000000000000000000000000000000000000..5629c5af8e0d8e6a46b330ec64107da63c304abe --- /dev/null +++ b/src/Controller/RevisionOverviewController.php @@ -0,0 +1,157 @@ +<?php + +/** + * @file + * Contains \Drupal\entity\Controller\RevisionOverviewController. + */ + +namespace Drupal\entity\Controller; + +use Drupal\Component\Utility\Xss; +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Datetime\DateFormatterInterface; +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\entity\Revision\EntityRevisionLogInterface; +use Drupal\user\EntityOwnerInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides a controller which shows the revision history. + * + * This controller leverages the revision controller trait, which is agnostic to + * any entity type, by using the new interface + * \Drupal\entity\Revision\EntityRevisionLogInterface. + */ +class RevisionOverviewController extends ControllerBase { + + use RevisionControllerTrait; + + /** + * The date formatter. + * + * @var \Drupal\Core\Datetime\DateFormatterInterface + */ + protected $dateFormatter; + + /** + * Creates a new RevisionOverviewController instance. + * + * @param \Drupal\Core\Datetime\DateFormatterInterface $date_formatter + * The date formatter. + */ + public function __construct(DateFormatterInterface $date_formatter) { + $this->dateFormatter = $date_formatter; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static($container->get('date.formatter')); + } + + /** + * {@inheritdoc} + */ + protected function hasDeleteRevisionAccess(EntityInterface $entity) { + return $this->currentUser()->hasPermission("delete all {$entity->id()} revisions"); + } + + /** + * {@inheritdoc} + */ + protected function buildRevertRevisionLink(EntityInterface $entity_revision) { + if ($entity_revision->hasLinkTemplate('revision-revert')) { + return [ + 'title' => t('Revert'), + 'url' => $entity_revision->toUrl('revision-revert'), + ]; + } + } + + /** + * {@inheritdoc} + */ + protected function buildDeleteRevisionLink(EntityInterface $entity_revision) { + if ($entity_revision->hasLinkTemplate('revision-delete')) { + return [ + 'title' => t('Delete'), + 'url' => $entity_revision->toUrl('revision-delete'), + ]; + } + } + + /** + * Generates an overview table of older revisions of an entity. + * + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The route match. + * + * @return array + * A render array. + */ + public function revisionOverviewController(RouteMatchInterface $route_match) { + return $this->revisionOverview($route_match->getParameter($route_match->getRouteObject()->getOption('entity_type_id'))); + } + + /** + * {@inheritdoc} + */ + protected function getRevisionDescription(ContentEntityInterface $revision, $is_default = FALSE) { + /** @var \Drupal\Core\Entity\ContentEntityInterface|\Drupal\user\EntityOwnerInterface|\Drupal\entity\Revision\EntityRevisionLogInterface $revision */ + + if ($revision instanceof EntityRevisionLogInterface) { + // Use revision link to link to revisions that are not active. + $date = $this->dateFormatter->format($revision->getRevisionCreationTime(), 'short'); + $link = $revision->toLink($date, 'revision'); + + $username = [ + '#theme' => 'username', + '#account' => $revision->getRevisionUser(), + ]; + } + else { + $link = $revision->toLink($revision->label(), 'revision'); + $username = ''; + + } + + $markup = ''; + if ($revision instanceof EntityRevisionLogInterface) { + $markup = $revision->getRevisionLogMessage(); + } + + if ($username) { + $template = '{% trans %}{{ date }} by {{ username }}{% endtrans %}{% if message %}<p class="revision-log">{{ message }}</p>{% endif %}'; + } + else { + $template = '{% trans %} {{ date }} {% endtrans %}{% if message %}<p class="revision-log">{{ message }}</p>{% endif %}'; + } + + $column = [ + 'data' => [ + '#type' => 'inline_template', + '#template' => $template, + '#context' => [ + 'date' => $link->toString(), + 'username' => $username, + 'message' => ['#markup' => $markup, '#allowed_tags' => Xss::getHtmlTagList()], + ], + ], + ]; + return $column; + } + + /** + * {@inheritdoc} + */ + protected function hasRevertRevisionAccess(EntityInterface $entity) { + return AccessResult::allowedIfHasPermission($this->currentUser(), "revert all {$entity->getEntityTypeId()} revisions")->orIf( + AccessResult::allowedIfHasPermission($this->currentUser(), "revert {$entity->bundle()} {$entity->getEntityTypeId()} revisions") + ); + } + +} diff --git a/src/Plugin/Derivative/RevisionsOverviewDeriver.php b/src/Plugin/Derivative/RevisionsOverviewDeriver.php new file mode 100644 index 0000000000000000000000000000000000000000..b7e308bfc2f0e17e72709499f9feb2cfe51cd285 --- /dev/null +++ b/src/Plugin/Derivative/RevisionsOverviewDeriver.php @@ -0,0 +1,72 @@ +<?php + +/** + * @file + * Contains \Drupal\entity\Plugin\Derivative\RevisionsOverviewDeriver. + */ + +namespace Drupal\entity\Plugin\Derivative; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; +use Symfony\Component\DependencyInjection\ContainerInterface; + +/** + * Provides local tasks for the revision overview. + */ +class RevisionsOverviewDeriver extends DeriverBase implements ContainerDeriverInterface { + + /** + * The entity type manager. + * + * @var \Drupal\Core\Entity\EntityTypeManagerInterface + */ + protected $entityTypeManager; + + /** + * Creates a new RevisionsOverviewDeriver instance. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + */ + public function __construct(\Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager) { + $this->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) { + $exclude = ['node']; + + $this->derivatives = []; + foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) { + if (in_array($entity_type_id, $exclude)) { + continue; + } + + if (!$entity_type->hasLinkTemplate('version-history')) { + continue; + } + + $this->derivatives[$entity_type_id] = [ + 'route_name' => "entity.$entity_type_id.version_history", + 'title' => 'Revisions', + 'base_route' => "entity.$entity_type_id.edit_form", + 'weight' => 20, + ] + $base_plugin_definition; + } + + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/src/Routing/RevisionRouteProvider.php b/src/Routing/RevisionRouteProvider.php index d539c6150b6083964ad09fcb87f688ff1f2b223b..33d2766f9d3af38c54c6abe78705b7b7dad3a9cf 100644 --- a/src/Routing/RevisionRouteProvider.php +++ b/src/Routing/RevisionRouteProvider.php @@ -30,6 +30,10 @@ class RevisionRouteProvider implements EntityRouteProviderInterface { $collection->add("entity.$entity_type_id.revision_revert_form", $view_route); } + if ($view_route = $this->getRevisionHistoryRoute($entity_type)) { + $collection->add("entity.$entity_type_id.version_history", $view_route); + } + return $collection; } @@ -97,4 +101,32 @@ class RevisionRouteProvider implements EntityRouteProviderInterface { } } + /** + * Gets the entity revision version history route. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getRevisionHistoryRoute($entity_type) { + if ($entity_type->hasLinkTemplate('version-history')) { + $entity_type_id = $entity_type->id(); + $route = new Route($entity_type->getLinkTemplate('version-history')); + $route->addDefaults([ + '_controller' => '\Drupal\entity\Controller\RevisionOverviewController::revisionOverviewController', + '_title' => 'Revisions', + ]); + $route->setRequirement('_entity_access_revision', "$entity_type_id.list"); + $route->setOption('entity_type_id', $entity_type->id()); + $route->setOption('parameters', [ + $entity_type->id() => [ + 'type' => 'entity:' . $entity_type->id(), + ], + ]); + return $route; + } + } + } diff --git a/tests/Kernel/RevisionOverviewIntegrationTest.php b/tests/Kernel/RevisionOverviewIntegrationTest.php new file mode 100644 index 0000000000000000000000000000000000000000..7ab784d8225594e82e9c1379801e9a6cdf67b39b --- /dev/null +++ b/tests/Kernel/RevisionOverviewIntegrationTest.php @@ -0,0 +1,56 @@ +<?php + +/** + * @file + * Contains \Drupal\Tests\entity\Kernel\RevisionOverviewIntegrationTest. + */ + +namespace Drupal\Tests\entity\Kernel; + +use Drupal\KernelTests\KernelTestBase; +use Symfony\Component\Routing\Route; + +/** + * Tests some integration of the revision overview: + * + * - Are the routes added properly. + * - Are the local tasks added properly. + */ +class RevisionOverviewIntegrationTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['node', 'entity_module_test', 'entity', 'user', 'system']; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->installSchema('system', 'router'); + + \Drupal::service('router.builder')->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.revisions_overview:entity_test_enhanced', $tasks); + $this->assertArrayNotHasKey('entity.revisions_overview:node', $tasks, 'Node should have been excluded because it provides their own'); + + $this->assertEquals('entity.entity_test_enhanced.version_history', $tasks['entity.revisions_overview:entity_test_enhanced']['route_name']); + $this->assertEquals('entity.entity_test_enhanced.edit_form', $tasks['entity.revisions_overview:entity_test_enhanced']['base_route']); + + /** @var \Drupal\Core\Routing\RouteProviderInterface $route_provider */ + $route_provider = \Drupal::service('router.route_provider'); + + $route = $route_provider->getRouteByName('entity.entity_test_enhanced.version_history'); + $this->assertInstanceOf(Route::class, $route); + $this->assertEquals('\Drupal\entity\Controller\RevisionOverviewController::revisionOverviewController', $route->getDefault('_controller')); + } + +} diff --git a/tests/modules/entity_module_test/src/Entity/EnhancedEntity.php b/tests/modules/entity_module_test/src/Entity/EnhancedEntity.php index d49e0bdcedfc0ca2842ad47eceb773446b0811c6..53c7ae441dc862b1ad97bd6f13c5dd6c4691954e 100644 --- a/tests/modules/entity_module_test/src/Entity/EnhancedEntity.php +++ b/tests/modules/entity_module_test/src/Entity/EnhancedEntity.php @@ -51,8 +51,9 @@ use Drupal\entity\Revision\RevisionableContentEntityBase; * "canonical" = "/entity_test_enhanced/{entity_test_enhanced}", * "revision" = "/entity_test_enhanced/{entity_test_enhanced}/revisions/{entity_test_enhanced_revision}/view", * "revision-revert-form" = "/entity_test_enhanced/{entity_test_enhanced}/revisions/{entity_test_enhanced_revision}/revert", + * "version-history" = "/entity_test_enhanced/{entity_test_enhanced}/revisions", * }, - * bundle_entity_type = "entity_test_enhanced_bundle" + * bundle_entity_type = "entity_test_enhanced_bundle", * ) */ class EnhancedEntity extends RevisionableContentEntityBase { diff --git a/tests/src/Kernel/RevisionBasicUITest.php b/tests/src/Kernel/RevisionBasicUITest.php index efb6f75ddb43ed663b3bd3f5adf5478354962ce6..5cb764926b49126b012daaaf03e1fadc3bac00be 100644 --- a/tests/src/Kernel/RevisionBasicUITest.php +++ b/tests/src/Kernel/RevisionBasicUITest.php @@ -43,6 +43,48 @@ class RevisionBasicUITest extends KernelTestBase { \Drupal::service('router.builder')->rebuild(); } + /** + * Tests the revision history controller. + */ + public function testRevisionHistory() { + $entity = EnhancedEntity::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->url('version-history')); + $response = $http_kernel->handle($request); + $this->assertEquals(403, $response->getStatusCode()); + + $role = Role::create(['id' => 'test_role']); + $role->grantPermission('view all entity_test_enhanced revisions'); + $role->grantPermission('administer entity_test_enhanced'); + $role->save(); + + $user = User::create([ + 'name' => 'Test user', + ]); + $user->addRole($role->id()); + \Drupal::service('account_switcher')->switchTo($user); + + $request = Request::create($revision->url('version-history')); + $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_enhanced/1/revisions/2/view') !== FALSE); + $this->assertTrue(strpos($response->getContent(), 'entity_test_enhanced/1') !== FALSE); + } + public function testRevisionView() { $entity = EnhancedEntity::create([ 'name' => 'rev 1',