Commit ef5c7f7c authored by catch's avatar catch
Browse files

Issue #2350939 by dpi, jibran, acbramley, Manuel Garcia, chr.fritsch,...

Issue #2350939 by dpi, jibran, acbramley, Manuel Garcia, chr.fritsch, AaronMcHale, Nono95230, capysara, darvanen, enyug, ravi.shankar, Spokje, thhafner, larowlan, smustgrave, mstrelan, mikestar5, andregp, joachim, shubhangi1995, nterbogt, mkalkbrenner, Berdir, Sam152, Xano: Implement a generic revision UI
parent e9d3e92f
Loading
Loading
Loading
Loading
+102 −0
Original line number Diff line number Diff line
<?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);
  }

}
+6 −0
Original line number Diff line number Diff line
@@ -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);
  }

+311 −0
Original line number Diff line number Diff line
<?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),
    ]);
  }

}
+283 −0
Original line number Diff line number Diff line
<?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;
  }

}
+327 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading