Issue #1984588 by dpi, larowlan, AaronMcHale, smustgrave, acbramley: Add Block Content revision UI

merge-requests/2999/merge
dpi 2022-12-12 20:39:24 +00:00 committed by Lee Rowlands
parent ae061b3974
commit 86caf6f378
10 changed files with 550 additions and 22 deletions

View File

@ -15,6 +15,12 @@ delete-multiple-form:
revision:
uri: https://drupal.org/link-relations/revision
description: A particular version of this resource.
revision-revert-form:
uri: https://drupal.org/link-relations/revision-revert-form
description: A form where a particular version of this resource can be reverted.
revision-delete-form:
uri: https://drupal.org/link-relations/revision-delete-form
description: A form where a particular version of this resource can be deleted.
create:
uri: https://drupal.org/link-relations/create
description: A REST resource URL where a resource of this type can be created.

View File

@ -11,6 +11,7 @@ use Drupal\Core\Entity\RevisionableInterface;
use Drupal\Core\Entity\RevisionableStorageInterface;
use Drupal\Core\Language\LanguageInterface;
use Drupal\Core\Language\LanguageManagerInterface;
use Drupal\Core\Link;
use Drupal\Core\Render\RendererInterface;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\Entity\RevisionLogInterface;
@ -155,10 +156,10 @@ class VersionHistoryController extends ControllerBase {
$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();
$url = $revision->hasLinkTemplate('revision') ? $revision->toUrl('revision') : NULL;
$context['revision'] = $url && $url->access()
? Link::fromTextAndUrl($linkText, $url)->toString()
: (string) $linkText;
$context['message'] = $revision instanceof RevisionLogInterface ? [
'#markup' => $revision->getRevisionLogMessage(),
'#allowed_tags' => Xss::getHtmlTagList(),

View File

@ -5,9 +5,33 @@
* Install, update and uninstall functions for the block_content module.
*/
use Drupal\Core\Entity\Form\RevisionDeleteForm;
use Drupal\Core\Entity\Form\RevisionRevertForm;
use Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider;
use Drupal\Core\StringTranslation\TranslatableMarkup;
/**
* Implements hook_update_last_removed().
*/
function block_content_update_last_removed() {
return 8600;
}
/**
* Update entity definition to handle revision routes.
*/
function block_content_update_10100(&$sandbox = NULL): TranslatableMarkup {
$entityDefinitionUpdateManager = \Drupal::entityDefinitionUpdateManager();
$definition = $entityDefinitionUpdateManager->getEntityType('block_content');
$routeProviders = $definition->get('route_provider');
$routeProviders['revision'] = RevisionHtmlRouteProvider::class;
$definition
->setFormClass('revision-delete', RevisionDeleteForm::class)
->setFormClass('revision-revert', RevisionRevertForm::class)
->set('route_provider', $routeProviders)
->setLinkTemplate('revision-delete-form', '/block/{block_content}/revision/{block_content_revision}/delete')
->setLinkTemplate('revision-revert-form', '/block/{block_content}/revision/{block_content_revision}/revert')
->setLinkTemplate('version-history', '/block/{block_content}/revisions');
$entityDefinitionUpdateManager->updateEntityType($definition);
return \t('Added revision routes to Custom block entity type.');
}

View File

@ -54,21 +54,34 @@ class BlockContentAccessControlHandler extends EntityAccessControlHandler implem
* {@inheritdoc}
*/
protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) {
// Allow view and update access to user with the 'edit any (type) block
// content' permission or the 'administer blocks' permission.
$edit_any_permission = 'edit any ' . $entity->bundle() . ' block content';
if ($operation === 'view') {
$access = AccessResult::allowedIf($entity->isPublished())
assert($entity instanceof BlockContentInterface);
$bundle = $entity->bundle();
$forbidIfNotDefaultAndLatest = fn () => AccessResult::forbiddenIf($entity->isDefaultRevision() && $entity->isLatestRevision());
$access = match ($operation) {
// Allow view and update access to user with the 'edit any (type) block
// content' permission or the 'administer blocks' permission.
'view' => AccessResult::allowedIf($entity->isPublished())
->orIf(AccessResult::allowedIfHasPermission($account, 'administer blocks'))
->orIf(AccessResult::allowedIfHasPermission($account, $edit_any_permission));
}
elseif ($operation === 'update') {
$access = AccessResult::allowedIfHasPermission($account, 'administer blocks')
->orIf(AccessResult::allowedIfHasPermission($account, $edit_any_permission));
}
else {
$access = parent::checkAccess($entity, $operation, $account);
}
->orIf(AccessResult::allowedIfHasPermission($account, 'edit any ' . $bundle . ' block content')),
'update' => AccessResult::allowedIfHasPermission($account, 'administer blocks')
->orIf(AccessResult::allowedIfHasPermission($account, 'edit any ' . $bundle . ' block content')),
// Revisions.
'view all revisions' => AccessResult::allowedIfHasPermissions($account, [
'administer blocks',
'view any ' . $bundle . ' block content history',
], 'OR'),
'revert' => AccessResult::allowedIfHasPermissions($account, [
'administer blocks',
'revert any ' . $bundle . ' block content revisions',
], 'OR')->orIf($forbidIfNotDefaultAndLatest()),
'delete revision' => AccessResult::allowedIfHasPermissions($account, [
'administer blocks',
'delete any ' . $bundle . ' block content revisions',
], 'OR')->orIf($forbidIfNotDefaultAndLatest()),
default => parent::checkAccess($entity, $operation, $account),
};
// Add the entity as a cacheable dependency because access will at least be
// determined by whether the block is reusable.

View File

@ -3,17 +3,40 @@
namespace Drupal\block_content;
use Drupal\block_content\Entity\BlockContentType;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\BundlePermissionHandlerTrait;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Provide dynamic permissions for blocks of different types.
*/
class BlockContentPermissions {
class BlockContentPermissions implements ContainerInjectionInterface {
use StringTranslationTrait;
use BundlePermissionHandlerTrait;
/**
* Constructs a BlockContentPermissions instance.
*
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager
* Entity type manager.
*/
public function __construct(
protected EntityTypeManagerInterface $entityTypeManager,
) {
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity_type.manager'),
);
}
/**
* Build permissions for each block type.
*
@ -21,7 +44,7 @@ class BlockContentPermissions {
* The block type permissions.
*/
public function blockTypePermissions() {
return $this->generatePermissions(BlockContentType::loadMultiple(), [$this, 'buildPermissions']);
return $this->generatePermissions($this->entityTypeManager->getStorage('block_content_type')->loadMultiple(), [$this, 'buildPermissions']);
}
/**
@ -40,6 +63,15 @@ class BlockContentPermissions {
"edit any $type_id block content" => [
'title' => $this->t('%type_name: Edit any block content', $type_params),
],
"view any $type_id block content history" => [
'title' => $this->t('%type_name: View any block content history pages', $type_params),
],
"revert any $type_id block content revisions" => [
'title' => $this->t('%type_name: Revert any block content revisions', $type_params),
],
"delete any $type_id block content revisions" => [
'title' => $this->t('%type_name: Delete any block content revisions', $type_params),
],
];
}

View File

@ -34,7 +34,12 @@ use Drupal\user\UserInterface;
* "add" = "Drupal\block_content\BlockContentForm",
* "edit" = "Drupal\block_content\BlockContentForm",
* "delete" = "Drupal\block_content\Form\BlockContentDeleteForm",
* "default" = "Drupal\block_content\BlockContentForm"
* "default" = "Drupal\block_content\BlockContentForm",
* "revision-delete" = \Drupal\Core\Entity\Form\RevisionDeleteForm::class,
* "revision-revert" = \Drupal\Core\Entity\Form\RevisionRevertForm::class,
* },
* "route_provider" = {
* "revision" = \Drupal\Core\Entity\Routing\RevisionHtmlRouteProvider::class,
* },
* "translation" = "Drupal\block_content\BlockContentTranslationHandler"
* },
@ -50,6 +55,9 @@ use Drupal\user\UserInterface;
* "edit-form" = "/block/{block_content}",
* "collection" = "/admin/structure/block/block-content",
* "create" = "/block",
* "revision-delete-form" = "/block/{block_content}/revision/{block_content_revision}/delete",
* "revision-revert-form" = "/block/{block_content}/revision/{block_content_revision}/revert",
* "version-history" = "/block/{block_content}/revisions",
* },
* translatable = TRUE,
* entity_keys = {

View File

@ -0,0 +1,85 @@
<?php
declare(strict_types = 1);
namespace Drupal\Tests\block_content\Functional;
/**
* Block content revision delete form test.
*
* @group block_content
* @coversDefaultClass \Drupal\Core\Entity\Form\RevisionDeleteForm
*/
class BlockContentRevisionDeleteTest extends BlockContentTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected $permissions = [
'view any basic block content history',
'delete any basic block content revisions',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->drupalPlaceBlock('page_title_block');
}
/**
* Tests revision delete.
*/
public function testDeleteForm(): void {
$entity = $this->createBlockContent(save: FALSE)
->setRevisionCreationTime((new \DateTimeImmutable('11 January 2009 4pm'))->getTimestamp())
->setRevisionTranslationAffected(TRUE);
$entity->setNewRevision();
$entity->save();
$revisionId = $entity->getRevisionId();
// Cannot delete latest revision.
$this->drupalGet($entity->toUrl('revision-delete-form'));
$this->assertSession()->statusCodeEquals(403);
// Create a new latest revision.
$entity
->setRevisionCreationTime((new \DateTimeImmutable('11 January 2009 5pm'))->getTimestamp())
->setRevisionTranslationAffected(TRUE)
->setNewRevision();
$entity->save();
// Reload the entity.
$revision = \Drupal::entityTypeManager()->getStorage('block_content')
->loadRevision($revisionId);
$this->drupalGet($revision->toUrl('revision-delete-form'));
$this->assertSession()->pageTextContains('Are you sure you want to delete the revision from Sun, 01/11/2009 - 16:00?');
$this->assertSession()->buttonExists('Delete');
$this->assertSession()->linkExists('Cancel');
$countRevisions = static function (): int {
return (int) \Drupal::entityTypeManager()->getStorage('block_content')
->getQuery()
->accessCheck(FALSE)
->allRevisions()
->count()
->execute();
};
$count = $countRevisions();
$this->submitForm([], 'Delete');
$this->assertEquals($count - 1, $countRevisions());
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->addressEquals(sprintf('block/%s/revisions', $entity->id()));
$this->assertSession()->pageTextContains(sprintf('Revision from Sun, 01/11/2009 - 16:00 of basic %s has been deleted.', $entity->label()));
$this->assertSession()->elementsCount('css', 'table tbody tr', 1);
}
}

View File

@ -0,0 +1,87 @@
<?php
declare(strict_types = 1);
namespace Drupal\Tests\block_content\Functional;
/**
* Block content revision form test.
*
* @group block_content
* @coversDefaultClass \Drupal\Core\Entity\Form\RevisionRevertForm
*/
class BlockContentRevisionRevertTest extends BlockContentTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected $permissions = [
'view any basic block content history',
'revert any basic block content revisions',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
$this->drupalPlaceBlock('page_title_block');
}
/**
* Tests revision revert.
*/
public function testRevertForm(): void {
$entity = $this->createBlockContent(save: FALSE)
->setRevisionCreationTime((new \DateTimeImmutable('11 January 2009 4pm'))->getTimestamp())
->setRevisionTranslationAffected(TRUE);
$entity->setNewRevision();
$entity->save();
$revisionId = $entity->getRevisionId();
// Cannot revert latest revision.
$this->drupalGet($entity->toUrl('revision-revert-form'));
$this->assertSession()->statusCodeEquals(403);
// Create a new latest revision.
$entity
->setRevisionCreationTime((new \DateTimeImmutable('11 January 2009 5pm'))->getTimestamp())
->setRevisionTranslationAffected(TRUE)
->setNewRevision();
$entity->save();
// Reload the entity.
$revision = \Drupal::entityTypeManager()->getStorage('block_content')
->loadRevision($revisionId);
$this->drupalGet($revision->toUrl('revision-revert-form'));
$this->assertSession()->pageTextContains('Are you sure you want to revert to the revision from Sun, 01/11/2009 - 16:00?');
$this->assertSession()->buttonExists('Revert');
$this->assertSession()->linkExists('Cancel');
$countRevisions = static function (): int {
return (int) \Drupal::entityTypeManager()->getStorage('block_content')
->getQuery()
->accessCheck(FALSE)
->allRevisions()
->count()
->execute();
};
$count = $countRevisions();
$this->submitForm([], 'Revert');
$this->assertEquals($count + 1, $countRevisions());
$this->assertSession()->statusCodeEquals(200);
$this->assertSession()->addressEquals(sprintf('block/%s/revisions', $entity->id()));
$this->assertSession()->pageTextContains(sprintf('basic %s has been reverted to the revision from Sun, 01/11/2009 - 16:00.', $entity->label()));
// Three rows, from the top: the newly reverted revision, the revision from
// 5pm, and the revision from 4pm.
$this->assertSession()->elementsCount('css', 'table tbody tr', 3);
}
}

View File

@ -0,0 +1,94 @@
<?php
declare(strict_types = 1);
namespace Drupal\Tests\block_content\Functional;
/**
* Block content version history test.
*
* @group block_content
* @coversDefaultClass \Drupal\Core\Entity\Controller\VersionHistoryController
*/
class BlockContentRevisionVersionHistoryTest extends BlockContentTestBase {
/**
* {@inheritdoc}
*/
protected $defaultTheme = 'stark';
/**
* {@inheritdoc}
*/
protected $permissions = [
'view any basic block content history',
'revert any basic block content revisions',
'delete any basic block content revisions',
];
/**
* {@inheritdoc}
*/
protected function setUp(): void {
parent::setUp();
$this->drupalLogin($this->adminUser);
}
/**
* Tests version history page.
*/
public function testVersionHistory(): void {
$entity = $this->createBlockContent(save: FALSE);
$entity
->setInfo('first revision')
->setRevisionCreationTime((new \DateTimeImmutable('1st June 2020 7am'))->getTimestamp())
->setRevisionLogMessage('first revision log')
->setRevisionUser($this->drupalCreateUser(name: 'first author'))
->setNewRevision();
$entity->save();
$entity
->setInfo('second revision')
->setRevisionCreationTime((new \DateTimeImmutable('2nd June 2020 8am'))->getTimestamp())
->setRevisionLogMessage('second revision log')
->setRevisionUser($this->drupalCreateUser(name: 'second author'))
->setNewRevision();
$entity->save();
$entity
->setInfo('third revision')
->setRevisionCreationTime((new \DateTimeImmutable('3rd June 2020 9am'))->getTimestamp())
->setRevisionLogMessage('third revision log')
->setRevisionUser($this->drupalCreateUser(name: 'third author'))
->setNewRevision();
$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.
$row1 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(1)');
// Latest revision does not have revert or delete revision operation.
$this->assertSession()->elementNotExists('named', ['link', 'Revert'], $row1);
$this->assertSession()->elementNotExists('named', ['link', 'Delete'], $row1);
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'Current revision');
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', 'third revision log');
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(1)', '06/03/2020 - 09:00 by third author');
$row2 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(2)');
$this->assertSession()->elementExists('named', ['link', 'Revert'], $row2);
$this->assertSession()->elementExists('named', ['link', 'Delete'], $row2);
$this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(2)', 'Current revision');
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(2)', 'second revision log');
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(2)', '06/02/2020 - 08:00 by second author');
$row3 = $this->assertSession()->elementExists('css', 'table tbody tr:nth-child(3)');
$this->assertSession()->elementExists('named', ['link', 'Revert'], $row3);
$this->assertSession()->elementExists('named', ['link', 'Delete'], $row3);
$this->assertSession()->elementTextNotContains('css', 'table tbody tr:nth-child(2)', 'Current revision');
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(3)', 'first revision log');
$this->assertSession()->elementTextContains('css', 'table tbody tr:nth-child(3)', '06/01/2020 - 07:00 by first author');
}
}

View File

@ -97,11 +97,45 @@ class BlockContentAccessHandlerTest extends KernelTestBase {
}
/**
* Test block content entity access.
*
* @param string $operation
* The entity operation to test.
* @param bool $published
* Whether the latest revision should be published.
* @param bool $reusable
* Whether the block content should be reusable. Non-reusable blocks are
* typically used in Layout Builder.
* @param array $permissions
* Permissions to grant to the test user.
* @param string|null $parent_access
* Whether the test user has access to the parent entity, valid values are
* 'allowed', 'forbidden', or 'neutral'. Set to NULL to assert parent will
* not be called.
* @param string $expected_access
* The expected access for the user and block content. Valid values are
* 'allowed', 'forbidden', or 'neutral'.
* @param bool $isLatest
* Whether the block content should be the latest revision when checking
* access. If FALSE, multiple revisions will be created, and an older
* revision will be loaded before checking access.
*
* @covers ::checkAccess
*
* @dataProvider providerTestAccess
*/
public function testAccess($operation, $published, $reusable, $permissions, $parent_access, $expected_access) {
public function testAccess(string $operation, bool $published, bool $reusable, array $permissions, ?string $parent_access, string $expected_access, bool $isLatest = TRUE) {
/** @var \Drupal\Core\Entity\RevisionableStorageInterface $entityStorage */
$entityStorage = \Drupal::entityTypeManager()->getStorage('block_content');
$loadRevisionId = NULL;
if (!$isLatest) {
// Save a historical revision, then setup for a new revision to be saved.
$this->blockEntity->save();
$loadRevisionId = $this->blockEntity->getRevisionId();
$this->blockEntity = $entityStorage->createRevision($this->blockEntity);
}
$published ? $this->blockEntity->setPublished() : $this->blockEntity->setUnpublished();
$reusable ? $this->blockEntity->setReusable() : $this->blockEntity->setNonReusable();
@ -144,6 +178,11 @@ class BlockContentAccessHandlerTest extends KernelTestBase {
}
$this->blockEntity->save();
// Reload a previous revision.
if ($loadRevisionId !== NULL) {
$this->blockEntity = $entityStorage->loadRevision($loadRevisionId);
}
$result = $this->accessControlHandler->access($this->blockEntity, $operation, $user, TRUE);
switch ($expected_access) {
case 'allowed':
@ -387,6 +426,145 @@ class BlockContentAccessHandlerTest extends KernelTestBase {
'neutral',
],
];
// View all revisions:
$cases['view all revisions:none'] = [
'view all revisions',
TRUE,
TRUE,
[],
NULL,
'neutral',
];
$cases['view all revisions:administer blocks'] = [
'view all revisions',
TRUE,
TRUE,
['administer blocks'],
NULL,
'allowed',
];
$cases['view all revisions:view bundle'] = [
'view all revisions',
TRUE,
TRUE,
['view any square block content history'],
NULL,
'allowed',
];
// Revert revisions:
$cases['revert:none:latest'] = [
'revert',
TRUE,
TRUE,
[],
NULL,
'forbidden',
TRUE,
];
$cases['revert:none:historical'] = [
'revert',
TRUE,
TRUE,
[],
NULL,
'neutral',
FALSE,
];
$cases['revert:administer blocks:latest'] = [
'revert',
TRUE,
TRUE,
['administer blocks'],
NULL,
'forbidden',
TRUE,
];
$cases['revert:administer blocks:historical'] = [
'revert',
TRUE,
TRUE,
['administer blocks'],
NULL,
'allowed',
FALSE,
];
$cases['revert:revert bundle:latest'] = [
'revert',
TRUE,
TRUE,
['administer blocks'],
NULL,
'forbidden',
TRUE,
];
$cases['revert:revert bundle:historical'] = [
'revert',
TRUE,
TRUE,
['revert any square block content revisions'],
NULL,
'allowed',
FALSE,
];
// Delete revisions:
$cases['delete revision:none:latest'] = [
'delete revision',
TRUE,
TRUE,
[],
NULL,
'forbidden',
TRUE,
];
$cases['delete revision:none:historical'] = [
'delete revision',
TRUE,
TRUE,
[],
NULL,
'neutral',
FALSE,
];
$cases['delete revision:administer blocks:latest'] = [
'delete revision',
TRUE,
TRUE,
['administer blocks'],
NULL,
'forbidden',
TRUE,
];
$cases['delete revision:administer blocks:historical'] = [
'delete revision',
TRUE,
TRUE,
['administer blocks'],
NULL,
'allowed',
FALSE,
];
$cases['delete revision:delete bundle:latest'] = [
'delete revision',
TRUE,
TRUE,
['administer blocks'],
NULL,
'forbidden',
TRUE,
];
$cases['delete revision:delete bundle:historical'] = [
'delete revision',
TRUE,
TRUE,
['delete any square block content revisions'],
NULL,
'allowed',
FALSE,
];
return $cases;
}