diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 3a2206305b5e2671467dc47a56b65b30791a538c..3c4c4d08e4e6841c39d9501e435b6f91f9a4ec41 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -9,7 +9,7 @@ include: variables: OPT_IN_TEST_NEXT_MINOR: 1 OPT_IN_TEST_NEXT_MAJOR: 1 - _CSPELL_WORDS: 'Csvg, Cpath' + _CSPELL_WORDS: 'Csvg, Cpath, purgeable' composer-lint: allow_failure: false diff --git a/src/Access/TrashAccessCheck.php b/src/Access/TrashAccessCheck.php new file mode 100644 index 0000000000000000000000000000000000000000..43e76e188b5ba142c371212611890b9703faf6f5 --- /dev/null +++ b/src/Access/TrashAccessCheck.php @@ -0,0 +1,140 @@ +<?php + +namespace Drupal\trash\Access; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Routing\Access\AccessInterface; +use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Session\AccountInterface; +use Drupal\trash\TrashManagerInterface; +use Symfony\Component\Routing\Route; + +/** + * Access checker for routes with a '_trash_access' requirement. + * + * The basic concept is that translators are free to restore/purge translations + * when they have access to update translations. Restoring or purging the + * original language requires explicit permissions to do so. + * + * Translations can not be restored if the original language is deleted and the + * user does not also have access to also restore it. + * + * Expected operations for '_trash_access' and what they check access for: + * - 'restore': Restore the original language. + * - 'purge': Purge an entity, only allowed on original languages. + * - 'purge-translation': Purge a single translation. + * - 'restore-translation': Restore a single translation, and the original + * language if it is also deleted. + */ +class TrashAccessCheck implements AccessInterface { + + /** + * Constructs an access checker. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager + * The language manager. + * @param \Drupal\trash\TrashManagerInterface $trashManager + * The trash manager. + */ + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected LanguageManagerInterface $languageManager, + protected TrashManagerInterface $trashManager, + ) {} + + /** + * Checks translation access for the entity and operation on the given route. + * + * @param \Symfony\Component\Routing\Route $route + * The route to check against. + * @param \Drupal\Core\Routing\RouteMatchInterface $route_match + * The parametrized route. + * @param \Drupal\Core\Session\AccountInterface $account + * The currently logged in account. + * @param \Drupal\Core\Language\LanguageInterface|null $language + * The language for the entity translation to check access for. + * @param string|null $entity_type_id + * (optional) The entity type ID. + * + * @return \Drupal\Core\Access\AccessResultInterface + * The access result. + */ + public function access(Route $route, RouteMatchInterface $route_match, AccountInterface $account, ?LanguageInterface $language = NULL, ?string $entity_type_id = NULL) { + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + if (!$this->trashManager->isEntityTypeEnabled($entity_type)) { + return AccessResult::forbidden('Unsupported entity type') + ->addCacheableDependency($entity_type); + } + /** @var \Drupal\Core\Entity\ContentEntityInterface|null $entity */ + $entity = $route_match->getParameter($entity_type_id); + if (NULL === $entity) { + return AccessResult::forbidden('No entity parameter') + ->addCacheableDependency($entity_type); + } + $operation = $route->getRequirement('_trash_access'); + if ($language && $entity->hasTranslation($language->getId())) { + $entity = $entity->getTranslation($language->getId()); + } + if (!trash_entity_is_deleted($entity)) { + return AccessResult::forbidden('Entity is not deleted') + ->addCacheableDependency($entity); + } + + if ($entity->isDefaultTranslation() && in_array($operation, [ + 'restore-translation', + 'purge-translation', + ])) { + return AccessResult::forbidden('Unsupported operation') + ->addCacheableDependency($entity); + } + if ($operation === 'restore-translation') { + // Restoring a translation with a deleted original language would also + // need to restore the original language. + if (trash_entity_is_deleted($entity->getUntranslated())) { + // This depends entirely on if the original language can be restored. + return AccessResult::allowedIfHasPermission($account, "restore {$entity->getEntityTypeId()} entities") + ->addCacheableDependency($entity); + } + try { + // If possible, fall back on normal translation access. + return $this->trashManager->executeInTrashContext('ignore', function () use ($entity) { + /** @var \Drupal\content_translation\ContentTranslationHandlerInterface $handler */ + $handler = $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'translation'); + return $handler->getTranslationAccess($entity, 'update'); + }); + } + catch (\Throwable $throwable) { + // No working translation handler, fall back on default access. + } + return $entity->getUntranslated() + ->access('update', $account, TRUE); + } + elseif ($operation === 'purge-translation') { + $result = AccessResult::allowedIfHasPermission($account, "purge {$entity->getEntityTypeId()} entities") + ->addCacheableDependency($entity); + if ($result->isAllowed()) { + return $result; + } + try { + return $this->trashManager->executeInTrashContext('ignore', function () use ($entity) { + /** @var \Drupal\content_translation\ContentTranslationHandlerInterface $handler */ + $handler = $this->entityTypeManager->getHandler($entity->getEntityTypeId(), 'translation'); + return $handler->getTranslationAccess($entity, 'delete'); + }); + } + catch (\Throwable $exception) { + // No working translation handler, fall back on default access. + } + return $entity->access('delete', $account, TRUE); + } + + return AccessResult::allowedIfHasPermission($account, "$operation $entity_type_id entities") + ->addCacheableDependency($entity); + } + +} diff --git a/src/Controller/TrashController.php b/src/Controller/TrashController.php index efbd5e0b332f793f93a60b60131e3b9377a93835..2489cf042cf533c25484f4b38c2f24ed61394361 100644 --- a/src/Controller/TrashController.php +++ b/src/Controller/TrashController.php @@ -13,6 +13,8 @@ use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\Core\Entity\TranslatableInterface; +use Drupal\Core\Language\LanguageInterface; use Drupal\trash\TrashManagerInterface; use Drupal\user\EntityOwnerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -23,11 +25,18 @@ use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; */ class TrashController extends ControllerBase implements ContainerInjectionInterface { + /** + * Is the site multilingual? + */ + protected bool $isMultilingual = FALSE; + public function __construct( protected TrashManagerInterface $trashManager, protected EntityTypeBundleInfoInterface $bundleInfo, protected DateFormatterInterface $dateFormatter, - ) {} + ) { + $this->isMultilingual = $this->languageManager()->isMultilingual(); + } /** * {@inheritdoc} @@ -97,6 +106,19 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf ]; $entity_type = $this->entityTypeManager()->getDefinition($entity_type_id); + if ($this->isMultilingual && $entity_type->isTranslatable()) { + $build['intro'] = [ + '#theme' => 'item_list', + '#title' => $this->t('Multilingual considerations'), + '#items' => [ + $this->t('Restoring a translation always restores the original language if it was deleted.'), + $this->t('Purging the original language also purges all translations.'), + $this->t('Purging a translation does not purge the original language.'), + $this->t("Translations may also be restored or purged from each entity's translation overview."), + ], + ]; + } + $build['table'] = [ '#type' => 'table', '#header' => $this->buildHeader($entity_type), @@ -107,8 +129,24 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf 'tags' => $entity_type->getListCacheTags(), ], ]; - foreach ($this->load($entity_type) as $entity) { - if ($row = $this->buildRow($entity)) { + /** @var \Drupal\Core\Entity\FieldableEntityInterface[] $entities */ + $entities = $this->load($entity_type); + $url_options = [ + 'language' => $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE), + 'query' => $this->getDestinationArray(), + ]; + foreach ($entities as $entity) { + if ($entity instanceof TranslatableInterface) { + foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { + if (($translation = $entity->getTranslation($langcode)) && + $translation->get('deleted')->value && + ($row = $this->buildRow($translation, $url_options)) + ) { + $build['table']['#rows'][$entity->id() . '_' . $langcode]['data'] = $row; + } + } + } + elseif ($row = $this->buildRow($entity, $url_options)) { $build['table']['#rows'][$entity->id()] = $row; } } @@ -152,6 +190,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf */ protected function buildHeader(EntityTypeInterface $entity_type): array { $row['label'] = $this->t('Title'); + $row['entity_id'] = $this->t('Id'); $row['bundle'] = $entity_type->getBundleLabel(); if ($entity_type->entityClassImplements(EntityOwnerInterface::class)) { $row['owner'] = $this->t('Author'); @@ -162,6 +201,9 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf if ($entity_type->entityClassImplements(RevisionLogInterface::class)) { $row['revision_user'] = $this->t('Deleted by'); } + if ($this->isMultilingual && $entity_type->isTranslatable()) { + $row['language'] = $this->t('Language'); + } $row['deleted'] = $this->t('Deleted on'); $row['operations'] = $this->t('Operations'); return $row; @@ -172,25 +214,31 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf * * @param \Drupal\Core\Entity\FieldableEntityInterface $entity * The entity for this row of the list. + * @param array $url_options + * URL options for operation links. * * @return array * A render array structure of fields for this entity. */ - protected function buildRow(FieldableEntityInterface $entity): array { + protected function buildRow(FieldableEntityInterface $entity, array $url_options): array { $entity_type = $entity->getEntityType(); if ($entity_type->getLinkTemplate('canonical') != $entity_type->getLinkTemplate('edit-form') && $entity->access('view')) { $row['label']['data'] = [ '#type' => 'link', - '#title' => "{$entity->label()} ({$entity->id()})", + '#title' => $entity->label(), '#url' => $entity->toUrl('canonical', ['query' => ['in_trash' => TRUE]]), ]; } else { $row['label']['data'] = [ - '#markup' => "{$entity->label()} ({$entity->id()})", + '#markup' => $entity->label(), ]; } + $row['entity_id']['data'] = [ + '#markup' => $entity->id(), + ]; + $row['bundle'] = $this->bundleInfo->getBundleInfo($entity->getEntityTypeId())[$entity->bundle()]['label']; if ($entity_type->entityClassImplements(EntityOwnerInterface::class)) { @@ -216,6 +264,18 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf $row['deleted'] = $this->dateFormatter->format($entity->get('deleted')->value, 'short'); + if ($this->isMultilingual && $entity->getEntityType()->isTranslatable()) { + if ($entity instanceof TranslatableInterface && $entity->isDefaultTranslation()) { + $row['language'] = $this->t('<strong>@language_name (Original language)</strong>', [ + '@language_name' => $entity->language() + ->getName(), + ]); + } + else { + $row['language'] = $entity->language()->getName(); + } + } + $list_builder = $this->entityTypeManager->hasHandler($entity_type->id(), 'list_builder') ? $this->entityTypeManager->getListBuilder($entity_type->id()) : $this->entityTypeManager->createHandlerInstance(EntityListBuilder::class, $entity_type); diff --git a/src/EventSubscriber/TrashLanguageOverviewSubscriber.php b/src/EventSubscriber/TrashLanguageOverviewSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..33ccab68f31c2400b197eb1f3e7a5d25ce99fc77 --- /dev/null +++ b/src/EventSubscriber/TrashLanguageOverviewSubscriber.php @@ -0,0 +1,92 @@ +<?php + +namespace Drupal\trash\EventSubscriber; + +use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\Routing\RedirectDestinationTrait; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\trash\Controller\TrashController; +use Drupal\trash\TrashManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpKernel\Event\ViewEvent; +use Symfony\Component\HttpKernel\KernelEvents; + +/** + * Listens to the Kernel View event to alter the translation overview. + */ +class TrashLanguageOverviewSubscriber implements EventSubscriberInterface { + + use StringTranslationTrait; + use RedirectDestinationTrait; + + /** + * Construct the event subscriber. + * + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager + * The entity type manager. + * @param \Drupal\trash\TrashManagerInterface $trashManager + * The trash manager. + * @param \Drupal\Core\Language\LanguageManagerInterface $languageManager + * The language manager. + */ + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected TrashManagerInterface $trashManager, + protected LanguageManagerInterface $languageManager, + ) { + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[KernelEvents::VIEW] = ['onView', 1]; + return $events; + } + + /** + * Adds "deleted" status to the translation overview page. + * + * Most of the logic from ContentTranslationController is done again here to + * the correct revision and status for each language. It would have been + * easier if content_translation had provided this data in the render array. + * + * @param \Symfony\Component\HttpKernel\Event\ViewEvent $event + * The Kernel View event. + */ + public function onView(ViewEvent $event) { + if (is_array($event->getControllerResult()) && isset($event->getControllerResult()['content_translation_overview'])) { + $build = $event->getControllerResult(); + /** @var \Drupal\Core\Entity\EntityInterface $entity */ + $entity = $build['#entity']; + $entity_type = $entity->getEntityType(); + if (!$entity instanceof ContentEntityInterface || !$this->trashManager->isEntityTypeEnabled($entity_type)) { + return; + } + $operations_column = count($build['content_translation_overview']['#header']) - 1; + $status_column = $operations_column - 1; + $url_options = [ + 'language' => $this->languageManager->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE), + 'query' => $this->getDestinationArray(), + ]; + foreach (array_keys($this->languageManager->getLanguages()) as $row_index => $langcode) { + $row = &$build['content_translation_overview']['#rows'][$row_index]; + if (!$entity->hasTranslation($langcode) || !$entity->hasLinkTemplate('restore-translation') || !$entity->hasLinkTemplate('purge-translation')) { + continue; + } + /** @var \Drupal\Core\Entity\ContentEntityInterface $translation */ + $translation = $entity->getTranslation($langcode); + if ($translation && !$translation->get('deleted')->isEmpty()) { + $row[$status_column] = $this->t('Deleted'); + $links = TrashController::getOperations($translation, $url_options); + $row[$operations_column]['data']['#links'] = $links; + } + } + $event->setControllerResult($build); + } + } + +} diff --git a/src/Form/EntityPurgeForm.php b/src/Form/EntityPurgeForm.php index bd23be70ed01450f8379c88cdeb6bff6c2050e8a..fa0ba78dab3d6d298a882c481098816986fcb585 100644 --- a/src/Form/EntityPurgeForm.php +++ b/src/Form/EntityPurgeForm.php @@ -6,6 +6,7 @@ namespace Drupal\trash\Form; use Drupal\Core\Entity\ContentEntityConfirmFormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Form\WorkspaceSafeFormInterface; use Drupal\Core\Url; use Drupal\trash\TrashManager; @@ -34,6 +35,21 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase implements WorkspaceS * {@inheritdoc} */ public function getQuestion() { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->getEntity(); + if (!$entity->isDefaultTranslation()) { + return $this->t('Are you sure you want to permanently delete the @language translation of the @entity-type %label?', [ + '@language' => $entity->language()->getName(), + '@entity-type' => $this->getEntity()->getEntityType()->getSingularLabel(), + '%label' => $this->getEntity()->label(), + ]); + } + if (count($entity->getTranslationLanguages()) > 1) { + return $this->t('Are you sure you want to permanently delete the @entity-type %label and all translations?', [ + '@entity-type' => $this->getEntity()->getEntityType()->getSingularLabel(), + '%label' => $this->getEntity()->label() ?? $this->getEntity()->id(), + ]); + } return $this->t('Are you sure you want to permanently delete the @entity-type %label?', [ '@entity-type' => $this->getEntity()->getEntityType()->getSingularLabel(), '%label' => $this->getEntity()->label() ?? $this->getEntity()->id(), @@ -50,9 +66,43 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase implements WorkspaceS /** * {@inheritdoc} */ - public function buildForm(array $form, FormStateInterface $form_state) { + public function buildForm(array $form, FormStateInterface $form_state, ?LanguageInterface $language = NULL) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->getEntity(); + if ($language === NULL) { + $entity = $entity->getUntranslated(); + $language = $entity->language(); + } + if ($entity->hasTranslation($language->getId())) { + $form_state->set('langcode', $language->getId()); + } $form = parent::buildForm($form, $form_state); - $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->restoreFormAlter($form, $form_state); + + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->getEntity(); + if ($entity->isDefaultTranslation()) { + if (count($entity->getTranslationLanguages()) > 1) { + $languages = []; + foreach ($entity->getTranslationLanguages() as $language) { + $languages[] = $language->getName(); + } + + $form['deleted_translations'] = [ + '#theme' => 'item_list', + '#title' => $this->t('The following @entity-type translations will be permanently deleted:', [ + '@entity-type' => $entity->getEntityType()->getSingularLabel(), + ]), + '#items' => $languages, + ]; + + $form['actions']['submit']['#value'] = $this->t('Delete all translations'); + } + } + else { + $form['actions']['submit']['#value'] = $this->t('Delete @language translation', ['@language' => $entity->language()->getName()]); + } + + $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->purgeFormAlter($form, $form_state); return $form; } @@ -63,15 +113,29 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase implements WorkspaceS public function submitForm(array &$form, FormStateInterface $form_state) { /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ $entity = $this->getEntity(); - $message = $this->t('The @entity-type %label has been permanently deleted.', [ - '@entity-type' => $entity->getEntityType()->getSingularLabel(), - '%label' => $entity->label() ?? $entity->id(), - ]); - $entity->delete(); + if (!$entity->isDefaultTranslation()) { + $message = $this->t('The @entity-type %label @language translation has been permanently deleted.', [ + '@entity-type' => $entity->getEntityType()->getSingularLabel(), + '%label' => $entity->label() ?? $entity->id(), + '@language' => $entity->language()->getName(), + ]); + $untranslated_entity = $entity->getUntranslated(); + $untranslated_entity->removeTranslation($entity->language()->getId()); + $untranslated_entity->save(); + } + else { + $message = $this->t('The @entity-type %label has been permanently deleted.', [ + '@entity-type' => $entity->getEntityType()->getSingularLabel(), + '%label' => $entity->label() ?? $entity->id(), + ]); + $entity->delete(); + } + $form_state->setRedirectUrl($this->getRedirectUrl()); $this->messenger()->addStatus($message); + // @todo Change log message if only a translation was purged. $this->getLogger('trash')->info('@entity-type (@bundle): permanently deleted %label.', [ '@entity-type' => $entity->getEntityType()->getLabel(), '@bundle' => $this->entityTypeBundleInfo->getBundleInfo($entity->getEntityTypeId())[$entity->bundle()]['label'], diff --git a/src/Form/EntityRestoreForm.php b/src/Form/EntityRestoreForm.php index a2c751623ef6a8bb63b62fb8f64d0fbaec7300e3..c722556631ea684f515bb49d49d98e7dc8e08bc5 100644 --- a/src/Form/EntityRestoreForm.php +++ b/src/Form/EntityRestoreForm.php @@ -6,6 +6,7 @@ namespace Drupal\trash\Form; use Drupal\Core\Entity\ContentEntityConfirmFormBase; use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Language\LanguageInterface; use Drupal\Core\Form\WorkspaceSafeFormInterface; use Drupal\Core\Url; use Drupal\trash\TrashManager; @@ -53,14 +54,61 @@ class EntityRestoreForm extends ContentEntityConfirmFormBase implements Workspac * {@inheritdoc} */ public function getDescription() { - return NULL; + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->getEntity(); + $untranslated = $entity->getUntranslated(); + if (!$entity->isDefaultTranslation() && trash_entity_is_deleted($untranslated)) { + return $this->t('Restoring the @language translation will also restore the @original_language original language.', [ + '@language' => $entity->language()->getName(), + '@original_language' => $untranslated->language()->getName(), + ]); + } + + return ''; } /** * {@inheritdoc} */ - public function buildForm(array $form, FormStateInterface $form_state) { - $form = parent::buildForm($form, $form_state); + public function buildForm(array $form, FormStateInterface $form_state, ?LanguageInterface $language = NULL) { + /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ + $entity = $this->getEntity(); + if ($language === NULL) { + $entity = $entity->getUntranslated(); + $language = $entity->language(); + } + if ($entity->hasTranslation($language->getId())) { + $form_state->set('langcode', $language->getId()); + } + else { + $language = $entity->language(); + } + + $translation_languages = $entity->getTranslationLanguages(FALSE); + if (count($translation_languages) > 0) { + $translation_options = array_map(function (LanguageInterface $translation) use ($entity) { + return $this->t('@translation_label: %entity_label', [ + '@translation_label' => $translation->getName(), + '%entity_label' => $entity->getTranslation($translation->getId())->label(), + ]); + }, array_filter($translation_languages, + function (LanguageInterface $translation) use ($entity, $language) { + return $translation->getId() !== $entity->language()->getId() && + $translation->getId() !== $language->getId() && + trash_entity_is_deleted($entity->getTranslation($translation->getId())); + } + )); + if (!empty($translation_options)) { + $form['translations'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Translations'), + '#description' => $this->t('Other translations to also restore.'), + '#options' => $translation_options, + '#default_value' => $entity->getTranslation($language->getId())->isDefaultTranslation() ? array_keys($translation_languages) : [], + ]; + } + } + $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->restoreFormAlter($form, $form_state); return $form; @@ -72,15 +120,38 @@ class EntityRestoreForm extends ContentEntityConfirmFormBase implements Workspac public function submitForm(array &$form, FormStateInterface $form_state) { /** @var \Drupal\Core\Entity\ContentEntityInterface $entity */ $entity = $this->getEntity(); - $message = $this->t('The @entity-type %label has been restored from trash.', [ + $message = $this->t('The @entity-type <a href=":url">%label</a> has been restored from trash.', [ '@entity-type' => $entity->getEntityType()->getSingularLabel(), '%label' => $entity->label() ?? $entity->id(), + ':url' => $entity->toUrl()->toString(), ]); - trash_restore_entity($entity); - $form_state->setRedirectUrl($this->getRedirectUrl()); + $langcodes = [$entity->language()->getId()]; + $langcodes = array_merge($langcodes, array_keys(array_filter($form_state->getValue('translations', [])))); + // Restoring the original language ensures we don't end up in a weird + // situation where a translated entity's source translation looks purgeable + // in the trash but doing so unexpectedly also purges active translations. + if (!$entity->isDefaultTranslation()) { + $untranslated = $entity->getUntranslated(); + if (trash_entity_is_deleted($untranslated)) { + $langcodes[] = $untranslated->language()->getId(); + $message = $this->t('The @entity-type <a href=":entity_url">%label</a> (@language) and the <a href=":original_url">%original_label</a> (@original_language) original language have been restored from trash.', [ + '@entity-type' => $entity->getEntityType()->getSingularLabel(), + '%label' => $entity->label() ?? $entity->id(), + ':entity_url' => $entity->toUrl()->toString(), + '@language' => $entity->language()->getName(), + '%original_label' => $untranslated->label(), + '@original_language' => $untranslated->language()->getName(), + ':original_url' => $untranslated->toUrl()->toString(), + ]); + } + } + trash_restore_entity($entity, $langcodes); + $form_state->setRedirectUrl($this->getRedirectUrl()); $this->messenger()->addStatus($message); + + // @todo Change log message if only a translation was restored. $this->getLogger('trash')->info('@entity-type (@bundle): restored %label.', [ '@entity-type' => $entity->getEntityType()->getLabel(), '@bundle' => $this->entityTypeBundleInfo->getBundleInfo($entity->getEntityTypeId())[$entity->bundle()]['label'], diff --git a/src/Routing/RouteSubscriber.php b/src/Routing/RouteSubscriber.php index f2906126745c66ecd76e89e513783b334591a510..fc7c9aeea8d99d6c6051fb48ba9d2f43b105af29 100644 --- a/src/Routing/RouteSubscriber.php +++ b/src/Routing/RouteSubscriber.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Drupal\trash\Routing; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Language\LanguageManagerInterface; use Drupal\Core\Routing\RouteSubscriberBase; use Drupal\Core\Routing\RoutingEvents; use Drupal\trash\TrashManagerInterface; @@ -19,6 +20,7 @@ class RouteSubscriber extends RouteSubscriberBase { public function __construct( protected EntityTypeManagerInterface $entityTypeManager, protected TrashManagerInterface $trashManager, + protected LanguageManagerInterface $languageManager, ) {} /** @@ -27,13 +29,6 @@ class RouteSubscriber extends RouteSubscriberBase { protected function alterRoutes(RouteCollection $collection): void { foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) { if ($this->trashManager->isEntityTypeEnabled($entity_type)) { - if ($entity_type->hasLinkTemplate('canonical')) { - $base_path = $entity_type->getLinkTemplate('canonical'); - } - else { - $base_path = "/admin/content/trash/$entity_type_id/{" . $entity_type_id . '}'; - } - $parameters = [ $entity_type_id => [ 'type' => "entity:$entity_type_id", @@ -41,30 +36,76 @@ class RouteSubscriber extends RouteSubscriberBase { ]; // Add a route for the restore form. - $route = new Route($base_path . '/restore'); + $route = new Route($entity_type->getLinkTemplate('restore')); $route ->addDefaults([ '_entity_form' => "{$entity_type_id}.restore", 'entity_type_id' => $entity_type_id, ]) - ->setRequirement('_entity_access', "{$entity_type_id}.restore") + ->setRequirements([ + '_trash_access' => 'restore', + ]) ->setOption('parameters', $parameters) ->setOption('_admin_route', TRUE) ->setOption('_trash_route', TRUE); $collection->add("entity.$entity_type_id.restore", $route); // Add a route for the purge form. - $route = new Route($base_path . '/purge'); + $route = new Route($entity_type->getLinkTemplate('purge')); $route ->addDefaults([ '_entity_form' => "{$entity_type_id}.purge", 'entity_type_id' => $entity_type_id, ]) - ->setRequirement('_entity_access', "{$entity_type_id}.purge") + ->setRequirements([ + '_trash_access' => 'purge', + ]) ->setOption('parameters', $parameters) ->setOption('_admin_route', TRUE) ->setOption('_trash_route', TRUE); $collection->add("entity.$entity_type_id.purge", $route); + + $translation_parameters = [ + 'language' => [ + 'type' => 'language', + ], + ] + $parameters; + + if ($entity_type->hasLinkTemplate('restore-translation')) { + // Add a route for the restore form. + $route = new Route($entity_type->getLinkTemplate('restore-translation')); + $route + ->addDefaults([ + '_entity_form' => "{$entity_type_id}.restore", + 'entity_type_id' => $entity_type_id, + 'language' => NULL, + ]) + ->setRequirements([ + '_trash_access' => 'restore-translation', + ]) + ->setOption('parameters', $translation_parameters) + ->setOption('_admin_route', TRUE) + ->setOption('_trash_route', TRUE); + $collection->add("entity.$entity_type_id.restore_translation", $route); + } + + if ($entity_type->hasLinkTemplate('purge-translation')) { + // Add a route for the purge form. + $route = new Route($entity_type->getLinkTemplate('purge-translation')); + $route + ->addDefaults([ + '_entity_form' => "{$entity_type_id}.purge", + 'entity_type_id' => $entity_type_id, + 'language' => NULL, + ]) + ->setRequirements([ + '_trash_access' => 'purge-translation', + ]) + ->setOption('parameters', $translation_parameters) + ->setOption('_admin_route', TRUE) + ->setOption('_trash_route', TRUE); + $collection->add("entity.$entity_type_id.purge_translation", $route); + } } } } diff --git a/src/TrashManager.php b/src/TrashManager.php index dba305f1ddfe11df0d4a5cfce2fc9f9ba3db1989..9535cbeb412413c618df0c28ccafd4aca585926a 100644 --- a/src/TrashManager.php +++ b/src/TrashManager.php @@ -89,7 +89,7 @@ class TrashManager implements TrashManagerInterface { ->setLabel(t('Deleted')) ->setDescription(t('Time when the item got deleted')) ->setInternal(TRUE) - ->setTranslatable(FALSE) + ->setTranslatable(TRUE) ->setRevisionable(TRUE); $this->entityDefinitionUpdateManager->installFieldStorageDefinition('deleted', $entity_type->id(), 'trash', $storage_definition); diff --git a/src/TrashStorageTrait.php b/src/TrashStorageTrait.php index 680bf2062809e1f4918b62a872cc21f29428356c..9774c1f5faf8fc945acadbfd3545d115808ad03a 100644 --- a/src/TrashStorageTrait.php +++ b/src/TrashStorageTrait.php @@ -2,7 +2,13 @@ namespace Drupal\trash; +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityStorageException; use Drupal\Core\Entity\RevisionLogInterface; +use Drupal\Core\Entity\TranslatableInterface; +use Drupal\Core\Language\LanguageManagerInterface; +use Drupal\Core\TypedData\TranslationStatusInterface; +use Drupal\Core\Utility\Error; use Drupal\workspaces\WorkspaceInformationInterface; use Drupal\workspaces\WorkspaceManagerInterface; @@ -15,7 +21,8 @@ trait TrashStorageTrait { * {@inheritdoc} */ public function delete(array $entities) { - if ($this->getTrashManager()->getTrashContext() !== 'active') { + /** @var \Drupal\Core\Entity\ContentEntityInterface[] $entities */ + if (!$entities || $this->getTrashManager()->getTrashContext() !== 'active') { parent::delete($entities); return; } @@ -35,69 +42,127 @@ trait TrashStorageTrait { parent::delete($to_delete); $field_name = 'deleted'; + $request_time = \Drupal::time()->getRequestTime(); $revisionable = $this->getEntityType()->isRevisionable(); - foreach ($to_trash as $entity) { - // Allow code to run before soft-deleting. - $this->getTrashManager()->getHandler($this->entityTypeId)->preTrashDelete($entity); - $this->invokeHook('pre_trash_delete', $entity); + try { + $transaction = $this->database->startTransaction(); + + foreach ($to_trash as $entity) { + // Allow code to run before soft-deleting. + $this->getTrashManager()->getHandler($this->entityTypeId)->preTrashDelete($entity); + $this->invokeHook('pre_trash_delete', $entity); - $entity->set($field_name, \Drupal::time()->getRequestTime()); + foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { + $entity->getTranslation($langcode)->set($field_name, $request_time); + } - // Always create a new revision if the entity type is revisionable. - if ($revisionable) { - /** @var \Drupal\Core\Entity\RevisionableInterface $entity */ - $entity->setNewRevision(TRUE); + // Always create a new revision if the entity type is revisionable. + if ($revisionable) { + /** @var \Drupal\Core\Entity\RevisionableInterface $entity */ + $entity->setNewRevision(TRUE); - if ($entity instanceof RevisionLogInterface) { - $entity->setRevisionUserId(\Drupal::currentUser()->id()); - $entity->setRevisionCreationTime(\Drupal::time()->getRequestTime()); + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionUserId(\Drupal::currentUser()->id()); + $entity->setRevisionCreationTime(\Drupal::time()->getRequestTime()); + } } - } - $entity->save(); - // Allow code to run after soft-deleting. - $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashDelete($entity); - $this->invokeHook('trash_delete', $entity); + $entity->setSyncing(TRUE)->save(); + + // Allow code to run after soft-deleting. + $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashDelete($entity); + $this->invokeHook('trash_delete', $entity); + } + } + catch (\Exception $e) { + if (isset($transaction)) { + $transaction->rollBack(); + } + Error::logException(\Drupal::logger($this->entityTypeId), $e); + throw new EntityStorageException($e->getMessage(), $e->getCode(), $e); } } /** * Restores soft-deleted entities. * - * @param array $entities + * @param \Drupal\Core\Entity\ContentEntityInterface[] $entities * An array of entity objects to restore. + * @param string[] $langcodes + * The translations to restore, defaults to all. * * @throws \Drupal\Core\Entity\EntityStorageException * In case of failures, an exception is thrown. */ - public function restoreFromTrash(array $entities) { - $field_name = 'deleted'; - $revisionable = $this->getEntityType()->isRevisionable(); - - foreach ($entities as $entity) { - // Allow code to run before restoring from trash. - $this->getTrashManager()->getHandler($this->entityTypeId)->preTrashRestore($entity); - $this->invokeHook('pre_trash_restore', $entity); - - $entity->set($field_name, NULL); - - // Always create a new revision if the entity type is revisionable. - if ($revisionable) { - /** @var \Drupal\Core\Entity\RevisionableInterface $entity */ - $entity->setNewRevision(TRUE); - - if ($entity instanceof RevisionLogInterface) { - $entity->setRevisionUserId(\Drupal::currentUser()->id()); - $entity->setRevisionCreationTime(\Drupal::time()->getRequestTime()); - } + public function restoreFromTrash(array $entities, array $langcodes = []) { + try { + $transaction = $this->database->startTransaction(); + // Need to be able to load trashed entities or loadUnchanged() will fail. + $this->getTrashManager() + ->executeInTrashContext('ignore', function () use ($entities, $langcodes) { + $field_name = 'deleted'; + foreach ($entities as $entity) { + // Allow code to run before restoring from trash. + $this->getTrashManager()->getHandler($this->entityTypeId)->preTrashRestore($entity); + $this->invokeHook('pre_trash_restore', $entity); + + $translation_langcodes = $langcodes ?: array_keys($entity->getTranslationLanguages()); + foreach ($translation_langcodes as $langcode) { + if ($entity->hasTranslation($langcode)) { + $entity->getTranslation($langcode)->set($field_name, NULL); + } + + if ($entity instanceof RevisionLogInterface) { + $entity->setRevisionUserId(\Drupal::currentUser()->id()); + $entity->setRevisionCreationTime(\Drupal::time()->getRequestTime()); + } + } + $entity->setSyncing(TRUE)->save(); + + // Allow code to run after restoring from trash. + $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashRestore($entity); + $this->invokeHook('trash_restore', $entity); + } + }); + } + catch (\Exception $e) { + if (isset($transaction)) { + $transaction->rollBack(); } - $entity->save(); + Error::logException(\Drupal::logger($this->entityTypeId), $e); + throw new EntityStorageException($e->getMessage(), $e->getCode(), $e); + } + } - // Allow code to run after restoring from trash. - $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashRestore($entity); - $this->invokeHook('trash_restore', $entity); + /** + * {@inheritdoc} + */ + protected function doPreSave(EntityInterface $entity) { + if ($entity->isNew() || $this->getTrashManager()->getTrashContext() !== 'active') { + return parent::doPreSave($entity); } + // Need to be able to load trashed entities or loadUnchanged() will fail. + $this->getTrashManager()->executeInTrashContext('ignore', function () use ($entity) { + if ($entity instanceof TranslatableInterface && $entity instanceof TranslationStatusInterface) { + if (!isset($entity->original)) { + $entity->original = $this->loadUnchanged($entity->id()); + } + $field_name = 'deleted'; + // Check if any translation was removed before saving. + foreach (array_keys($this->getLanguageManager() + ->getLanguages()) as $langcode) { + if ($entity->getTranslationStatus($langcode) === TranslationStatusInterface::TRANSLATION_REMOVED) { + // Restore the removed translation and mark it deleted. + $entity->addTranslation($langcode, $entity->original->getTranslation($langcode) + ->toArray()); + $entity->getTranslation($langcode)->set($field_name, \Drupal::time() + ->getRequestTime()); + } + } + } + }); + return parent::doPreSave($entity); } /** @@ -196,6 +261,13 @@ trait TrashStorageTrait { return \Drupal::service('trash.manager'); } + /** + * Gets the language manager. + */ + private function getLanguageManager(): LanguageManagerInterface { + return \Drupal::service('language_manager'); + } + /** * Gets the workspace manager service. */ diff --git a/tests/modules/trash_test/config/optional/views.view.trash_test_view.yml b/tests/modules/trash_test/config/optional/views.view.trash_test_view.yml index 4e3cc3a37b7eddbab454cb9efbf5c2526359a839..1b19efb31aa0949a4b5b7c2f4fb5a89a6ac3f08e 100644 --- a/tests/modules/trash_test/config/optional/views.view.trash_test_view.yml +++ b/tests/modules/trash_test/config/optional/views.view.trash_test_view.yml @@ -8,7 +8,7 @@ label: trash_test_view module: views description: 'Various test views' tag: '' -base_table: trash_test +base_table: trash_test_field_data base_field: id core: 8.x display: @@ -67,7 +67,7 @@ display: fields: id: id: id - table: trash_test + table: trash_test_field_data field: id relationship: none group_type: group @@ -135,7 +135,7 @@ display: sorts: id: id: id - table: trash_test + table: trash_test_field_data field: id relationship: none group_type: group @@ -192,15 +192,17 @@ display: filters: Deleted: id: Deleted - table: trash_test + table: trash_test_field_data field: Deleted relationship: none group_type: group admin_label: '' - operator: '>' + operator: '<=' value: - value: '0' - type: 'date' + min: '' + max: '' + value: now + type: offset group: 1 exposed: false expose: @@ -209,12 +211,17 @@ display: description: '' use_operator: false operator: '' + operator_limit_selection: false + operator_list: { } identifier: '' required: false remember: false multiple: false remember_roles: authenticated: authenticated + min_placeholder: '' + max_placeholder: '' + placeholder: '' is_grouped: false group_info: label: '' diff --git a/tests/modules/trash_test/src/Entity/TrashTestEntity.php b/tests/modules/trash_test/src/Entity/TrashTestEntity.php index fbb75bc68be6931f8a4c60301d91aa15887464f3..9e9a11d5b564549b84ffab141fd9e128f452e252 100644 --- a/tests/modules/trash_test/src/Entity/TrashTestEntity.php +++ b/tests/modules/trash_test/src/Entity/TrashTestEntity.php @@ -3,6 +3,8 @@ namespace Drupal\trash_test\Entity; use Drupal\Core\Entity\ContentEntityBase; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Field\BaseFieldDefinition; /** * Provides a trash test entity. @@ -25,11 +27,16 @@ use Drupal\Core\Entity\ContentEntityBase; * }, * base_table = "trash_test", * revision_table = "trash_test_revision", + * data_table = "trash_test_field_data", + * revision_table = "trash_test_revision", + * revision_data_table = "trash_test_field_revision", + * translatable = TRUE, * entity_keys = { * "id" = "id", * "revision" = "revision", * "label" = "label", * "uuid" = "uuid", + * "langcode" = "langcode", * }, * links = { * "canonical" = "/trash_test/{trash_test}", @@ -40,4 +47,18 @@ use Drupal\Core\Entity\ContentEntityBase; */ class TrashTestEntity extends ContentEntityBase { + /** + * {@inheritdoc} + */ + public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { + $fields = parent::baseFieldDefinitions($entity_type); + $fields['label'] = BaseFieldDefinition::create('string') + ->setLabel('Label') + ->setRequired(TRUE) + ->setTranslatable(TRUE) + ->setRevisionable(TRUE) + ->setSetting('max_length', 255); + return $fields; + } + } diff --git a/tests/src/Functional/TrashMultilingualNodeTest.php b/tests/src/Functional/TrashMultilingualNodeTest.php new file mode 100644 index 0000000000000000000000000000000000000000..44cae8cebf9f7e0f3fc5ef899e22eee188cbe806 --- /dev/null +++ b/tests/src/Functional/TrashMultilingualNodeTest.php @@ -0,0 +1,550 @@ +<?php + +namespace Drupal\Tests\trash\Functional; + +use Drupal\Component\Render\FormattableMarkup; +use Drupal\Tests\BrowserTestBase; +use Drupal\language\Entity\ContentLanguageSettings; +use Drupal\node\NodeInterface; + +/** + * Tests the basic trash functionality on multilingual nodes. + * + * @group trash + */ +class TrashMultilingualNodeTest extends BrowserTestBase { + + /** + * A user with permission to trash content but not restoring. + * + * @var \Drupal\user\UserInterface + */ + protected $webUser; + + /** + * A user with permission to trash, restore and purge the trash bin. + * + * @var \Drupal\user\UserInterface + */ + protected $adminUser; + + /** + * {@inheritdoc} + */ + protected static $modules = ['block', 'node', 'trash', 'language']; + + /** + * {@inheritdoc} + */ + protected $defaultTheme = 'stark'; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + // Create Basic page node type. + if ($this->profile != 'standard') { + $this->drupalCreateContentType([ + 'type' => 'page', + 'name' => 'Basic Page', + 'display_submitted' => FALSE, + ]); + } + + $this->webUser = $this->drupalCreateUser([ + 'access content', + 'create page content', + 'edit own page content', + 'delete own page content', + 'access trash', + ]); + $this->adminUser = $this->drupalCreateUser([ + 'access content', + 'create page content', + 'edit own page content', + 'delete own page content', + 'edit any page content', + 'delete any page content', + 'administer nodes', + 'bypass node access', + 'access trash', + 'view deleted entities', + 'purge node entities', + 'restore node entities', + 'administer content types', + 'administer languages', + 'delete any page content', + ]); + $this->drupalLogin($this->adminUser); + $this->addLanguage('sv'); + $this->addLanguage('de'); + $this->drupalPlaceBlock('local_tasks_block', ['id' => 'page_tabs_block']); + $this->drupalPlaceBlock('local_actions_block', ['id' => 'page_actions_block']); + } + + /** + * Test moving a node to the trash bin and restoring it. + * + * @dataProvider languageProvider + */ + public function testTrashAndRestoreNode(string $langcode) { + // Login as a regular user. + $this->drupalLogin($this->webUser); + + // Create "Basic page" content with title. + $settings = [ + 'title' => $this->randomMachineName(8), + 'langcode' => $langcode, + ]; + $node = $this->drupalCreateNode($settings); + + // Load the node edit form. + $this->drupalGet('node/' . $node->id() . '/edit'); + + // Make sure the task is there. + $this->assertSession()->linkExists('Delete'); + + // Now edit the same node as an admin user. + $this->drupalLogin($this->adminUser); + $this->drupalGet('node/' . $node->id() . '/edit'); + + // Make sure we can move to the trash bin. + $this->assertSession()->linkExists('Delete'); + + // Make sure the link works as expected. + $this->clickLink('Delete'); + $this->assertSession()->addressEquals('node/1/delete'); + + $this->assertSession()->pageTextContains('Deleting this content item will move it to the trash. You can restore it from the trash at a later date if necessary.'); + $this->submitForm([], 'Delete'); + + // The content has been moved to the trash. + $this->assertSession()->statusMessageContains('The Basic Page ' . $node->getTitle() . ' has been deleted', 'status'); + // I can see it in the trash context. + $this->drupalGet('node/1', ['query' => ['in_trash' => 1]]); + + $this->assertSession()->elementExists('css', 'article.is-deleted'); + + // I can't see the node anymore with a regular editor. + $this->drupalLogin($this->webUser); + $this->drupalGet('node/' . $node->id()); + $this->assertSession()->statusCodeEquals(404); + + // I can restore the content. + $this->drupalLogin($this->adminUser); + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->linkExists('Restore'); + $this->clickLink('Restore'); + $this->assertSession()->addressEquals('/node/' . $node->id() . '/restore'); + $this->submitForm([], 'Confirm'); + $this->assertSession()->statusMessageContains('The content item ' . $node->getTitle() . ' has been restored from trash.', 'status'); + } + + /** + * Test moving a node to the trash bin and purging it. + * + * @dataProvider languageProvider + */ + public function testPurgingNode(string $langcode) { + // Login as a privileged user. + $this->drupalLogin($this->adminUser); + + // Create "Basic page" content with title. + $settings = [ + 'title' => $this->randomMachineName(8), + 'langcode' => $langcode, + ]; + $node = $this->drupalCreateNode($settings); + + // Load the node edit form. + $this->drupalGet('node/' . $node->id() . '/edit'); + $this->clickLink('Delete'); + $this->submitForm([], 'Delete'); + + // The content has been moved to the trash. + $this->assertSession()->statusMessageContains('The Basic Page ' . $node->getTitle() . ' has been deleted', 'status'); + + // Make sure we can Purge. + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->linkExists('Purge'); + $this->clickLink('Purge'); + $this->assertSession()->addressEquals('/node/' . $node->id() . '/purge'); + $this->assertSession()->pageTextContains('This action cannot be undone.'); + $this->submitForm([], 'Confirm'); + $this->assertSession()->statusMessageContains('The content item ' . $node->getTitle() . ' has been permanently deleted.', 'status'); + } + + /** + * Data provider for Content Translation module status. + * + * Content Translation really only adds a GUI to perform translation tasks, + * as entities may have translations as soon as Language module is enabled. + * The difference is whether interactive translation is presented to the user + * or not, and Trash should function even if translations were added + * programmatically, or Content Translation was disabled after adding some. + * + * @return array[] + * A data array keyed by language names holding arrays of the corresponding + * language code argument. + */ + public static function contentTranslationEnabledProvider(): array { + return [ + 'With Content Translation' => [TRUE], + 'Without Content Translation' => [FALSE], + ]; + } + + /** + * Test moving a node and translations to the trash bin and restoring them. + * + * @dataProvider contentTranslationEnabledProvider + */ + public function testTrashAndRestoreMultiNode(bool $enable_content_translation) { + if ($enable_content_translation) { + $this->enableContentTranslation(); + } + // Login as a regular editor. + $this->drupalLogin($this->webUser); + + // Create "Basic page" content with translated titles. + $english_title = $this->randomMachineName(8); + $swedish_title = $this->randomMachineName(8); + $german_title = $this->randomMachineName(8); + $settings = [ + 'title' => $english_title, + 'langcode' => 'en', + ]; + $node = $this->drupalCreateNode($settings); + + // Load the Swedish node add translation form. + $nid = $node->id(); + $this->addTranslation($node, 'en', 'sv', $swedish_title); + + // Load the German node add translation form. + $this->addTranslation($node, 'en', 'de', $german_title); + + // Load the English form. + $this->drupalGet("/node/$nid/edit"); + // Make sure we can move all translations to the trash bin in one go. + $this->clickLink('Delete'); + $this->assertSession()->addressEquals("/node/$nid/delete"); + $this->assertSession()->pageTextContains('Deleting this content item will move it to the trash.'); + $this->assertSession()->pageTextContains("The following content item translations will be deleted:EnglishSwedishGerman"); + $this->submitForm([], 'Delete all translations'); + // The content has been moved to the trash. + $this->assertSession()->statusMessageContains('The Basic Page ' . $node->getTitle() . ' has been deleted', 'status'); + + // The regular editor is no longer able to access any translation. + $this->drupalGet("/node/$nid"); + // Access is 404 because the node "does not exist". + $this->assertSession()->statusCodeEquals(404); + $this->drupalGet("/sv/node/$nid"); + $this->assertSession()->statusCodeEquals(404); + $this->drupalGet("/de/node/$nid"); + $this->assertSession()->statusCodeEquals(404); + + // The editor cannot restore any translations because the source is deleted, + // and they do not have access to restore it. + $this->drupalGet('/admin/content/trash'); + // Make some positive assertions before checking links do not exist. + $this->assertSession()->pageTextContains($english_title); + $this->assertSession()->pageTextContains($swedish_title); + $this->assertSession()->pageTextContains($german_title); + // This is a "contains()" check so covers all translations. + $this->assertSession()->linkByHrefNotExists("/node/$nid/restore"); + + // The admin can see all translations in the trash context. + $this->drupalLogin($this->adminUser); + $this->drupalGet("/node/$nid", ['query' => ['in_trash' => 1]]); + $this->assertSession()->pageTextContains($english_title); + $this->assertSession()->elementExists('css', 'article.is-deleted'); + $this->drupalGet("/sv/node/$nid", ['query' => ['in_trash' => 1]]); + $this->assertSession()->pageTextContains($swedish_title); + $this->assertSession()->elementExists('css', 'article.is-deleted'); + $this->drupalGet("/de/node/$nid", ['query' => ['in_trash' => 1]]); + $this->assertSession()->pageTextContains($german_title); + $this->assertSession()->elementExists('css', 'article.is-deleted'); + + // The admin can restore the main node and translations. + $this->drupalGet('/admin/content/trash'); + $base_path = base_path(); + $this->assertSession()->linkByHrefExistsExact("{$base_path}node/$nid/restore?destination={$base_path}admin/content/trash"); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/de"); + + // Check that attempting to restore Swedish will also restore English. + $this->drupalGet("/node/$nid/restore/sv"); + $this->assertSession()->pageTextContains('Restoring the Swedish translation will also restore the English original language.'); + // Same for German. + $this->drupalGet("/node/$nid/restore/de"); + $this->assertSession()->pageTextContains('Restoring the German translation will also restore the English original language.'); + // Restore only the English original language. + $this->drupalGet("/node/$nid/restore"); + $this->submitForm([ + 'translations[sv]' => FALSE, + 'translations[de]' => FALSE, + ], 'Confirm'); + $this->assertSession()->statusMessageContains("The content item $english_title has been restored from trash.", 'status'); + + // The regular editor can now restore translations from the trashcan page. + $this->drupalLogin($this->webUser); + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/de"); + + if ($enable_content_translation) { + // Also verify they can do it from the translation overview page. + $this->drupalGet("/node/$nid/translations"); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/de"); + $this->assertSession()->pageTextContains("English (Original language) $english_title Published"); + $this->assertSession()->pageTextContains("Swedish $swedish_title Deleted"); + $this->assertSession()->pageTextContains("German $german_title Deleted"); + } + + // Restore the German translation. + $this->drupalGet("/node/$nid/restore/de"); + $this->submitForm([], 'Confirm'); + $this->assertSession()->statusMessageContains("The content item $german_title has been restored from trash.", 'status'); + + // The regular editor can again see the German translation. + $this->drupalGet("/de/node/$nid"); + $this->assertSession()->statusCodeEquals(200); + + // Delete the English and German translations again. + $this->drupalGet("node/$nid/delete"); + $this->submitForm([], 'Delete all translations'); + $this->assertSession()->statusMessageContains("The Basic Page $english_title has been deleted.", 'status'); + + // The admin can restore a translation and the original language at once. + $this->drupalLogin($this->adminUser); + $this->drupalGet("/node/$nid/restore/sv"); + $this->assertSession()->pageTextContains('Restoring the Swedish translation will also restore the English original language.'); + $this->submitForm([], 'Confirm'); + $this->assertSession()->statusMessageContains("The content item $swedish_title (Swedish) and the $english_title (English) original language have been restored from trash.", 'status'); + + // Switch back to the editor. + $this->drupalLogin($this->webUser); + // Verify English and Swedish are visible and German is still inaccessible. + $this->drupalGet("/node/$nid"); + $this->assertSession()->statusCodeEquals(200); + $this->drupalGet("/sv/node/$nid"); + $this->assertSession()->statusCodeEquals(200); + $this->drupalGet("/de/node/$nid"); + $this->assertSession()->statusCodeEquals(403); + + // Switch back to the Admin and delete all translations. + $this->drupalLogin($this->adminUser); + $this->drupalGet("/node/$nid/delete"); + $this->submitForm([], 'Delete all translations'); + // Restore the English original language and only the German translation. + $this->drupalGet("/node/$nid/restore"); + $this->submitForm([ + 'translations[sv]' => FALSE, + 'translations[de]' => TRUE, + ], 'Confirm'); + + // Verify the Swedish translation is still deleted. + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->linkByHrefExists("/node/$nid/restore/sv"); + } + + /** + * Test moving a node and translations to the trash bin and purging them. + * + * @dataProvider contentTranslationEnabledProvider + */ + public function testPurgingMultiNode(bool $enable_content_translation) { + if ($enable_content_translation) { + $this->enableContentTranslation(); + } + // Login as a regular editor. + $this->drupalLogin($this->webUser); + + // Create "Basic page" content with translated titles. + $english_title = $this->randomMachineName(8); + $swedish_title = $this->randomMachineName(8); + $german_title = $this->randomMachineName(8); + $settings = [ + 'title' => $english_title, + 'langcode' => 'en', + ]; + $node = $this->drupalCreateNode($settings); + + // Load the Swedish node add translation form. + $nid = $node->id(); + $this->addTranslation($node, 'en', 'sv', $swedish_title); + + // Load the German node add translation form. + $this->addTranslation($node, 'en', 'de', $german_title); + + // Delete the node. + $this->drupalGet("/node/$nid/delete"); + $this->submitForm([], 'Delete all translations'); + + // The editor may purge translations but not the original. + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->pageTextContains($english_title); + $this->assertSession()->pageTextContains($swedish_title); + $this->assertSession()->pageTextContains($german_title); + $base_path = base_path(); + $this->assertSession()->linkByHrefNotExistsExact("{$base_path}node/$nid/purge?destination={$base_path}admin/content/trash"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/de"); + if ($enable_content_translation) { + // The translations overview is disabled because the original is deleted. + $this->drupalGet("/node/$nid/translations"); + $this->assertSession()->statusCodeEquals(404); + } + + // The admin may purge all the translations. + $this->drupalLogin($this->adminUser); + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->linkByHrefExists("/node/$nid/purge"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/de"); + + // Restore the English original language. + $this->assertSession()->linkByHrefExists("/node/$nid/restore"); + $this->drupalGet("/node/$nid/restore"); + $this->submitForm([ + 'translations[sv]' => FALSE, + 'translations[de]' => FALSE, + ], 'Confirm'); + + // The editor may now purge the translations. + $this->drupalLogin($this->webUser); + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/de"); + if ($enable_content_translation) { + // Also verify they can do it from the translation overview page. + $this->drupalGet("/node/$nid/translations"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/sv"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/de"); + $this->assertSession()->pageTextContains("Swedish $swedish_title Deleted"); + $this->assertSession()->pageTextContains("German $german_title Deleted"); + } + + // Purge the Swedish translation. + $this->drupalGet("/node/$nid/purge/sv"); + $this->assertSession()->pageTextContains("Are you sure you want to permanently delete the Swedish translation of the content item $swedish_title?"); + $this->submitForm([], 'Delete Swedish translation'); + // Check that the German translation was not removed. + $this->assertSession()->linkByHrefExists("/node/$nid/purge/de"); + // Check that the Swedish translation can be re-added. + if ($enable_content_translation) { + // Also verify they can do it from the translation overview page. + $this->drupalGet("/node/$nid/translations"); + $this->assertSession()->linkByHrefExists("/node/$nid/purge/de"); + $this->assertSession()->pageTextContains("Swedish N/A Not translated"); + $this->assertSession()->pageTextContains("German $german_title Deleted"); + } + $this->addTranslation($node, 'en', 'sv', $swedish_title); + } + + /** + * Data provider for language codes. + * + * @return array[] + * A data array keyed by language names holding arrays of the corresponding + * language code argument. + */ + public static function languageProvider(): array { + return [ + 'English' => ['en'], + 'Swedish' => ['sv'], + ]; + } + + /** + * Adds a language. + * + * @param string $langcode + * The language code of the language to add. + */ + protected function addLanguage($langcode) { + $edit = ['predefined_langcode' => $langcode]; + $this->drupalGet('admin/config/regional/language/add'); + $this->submitForm($edit, 'Add language'); + $this->container->get('language_manager')->reset(); + $this->assertNotEmpty(\Drupal::languageManager()->getLanguage($langcode), new FormattableMarkup('Language %langcode added.', ['%langcode' => $langcode])); + } + + /** + * Add a translation to a node. + * + * @param \Drupal\node\NodeInterface $node + * The node to add a translation to. + * @param string $source_langcode + * The source langcode. + * @param string $langcode + * The new translation langcode. + * @param string $title + * The new translation title. + */ + protected function addTranslation(NodeInterface $node, string $source_langcode, string $langcode, string $title) { + if ($this->container->get('module_handler')->moduleExists('content_translation')) { + $nid = $node->id(); + $this->drupalGet("/node/$nid/translations/add/$source_langcode/$langcode"); + $edit = [ + 'Title' => $title, + ]; + $this->submitForm($edit, 'Save (this translation)'); + $this->assertSession()->pageTextContains("$title has been updated"); + return; + } + $node = $this->drupalGetNodeByTitle($node->getUntranslated()->getTitle()); + $this->assertFalse($node->hasTranslation($langcode), 'Translation does not already exist'); + $translation = $node->addTranslation($langcode, [ + 'title' => $title, + ]); + $translation->save(); + $this->assertSame($title, $node->getTranslation($langcode) + ->getTitle(), 'Translation exists'); + } + + /** + * Enable and configure Content Translation module. + */ + protected function enableContentTranslation() { + $success = $this->container->get('module_installer') + ->install(['content_translation'], TRUE); + $this->assertTrue($success, 'Enabled Content Translation'); + + ContentLanguageSettings::loadByEntityTypeBundle('node', 'page') + ->setDefaultLangcode('en') + ->setLanguageAlterable(TRUE) + ->setThirdPartySetting('content_translation', 'enabled', TRUE) + ->save(); + + $editor_roles = $this->webUser->getRoles(TRUE); + /** @var \Drupal\user\RoleInterface $role */ + $role = \Drupal::entityTypeManager() + ->getStorage('user_role') + ->load(reset($editor_roles)); + $role->grantPermission('translate editable entities'); + $role->grantPermission('update content translations'); + $role->save(); + + $admin_roles = $this->adminUser->getRoles(TRUE); + /** @var \Drupal\user\RoleInterface $role */ + $role = \Drupal::entityTypeManager() + ->getStorage('user_role') + ->load(reset($admin_roles)); + $role->grantPermission('administer content translation'); + $role->grantPermission('delete content translations'); + $role->grantPermission('translate editable entities'); + $role->save(); + + /** @var \Drupal\user\UserStorageInterface $user_storage */ + $user_storage = $this->container->get('entity_type.manager') + ->getStorage('user'); + $user_storage->resetCache([$this->adminUser->id(), $this->webUser->id()]); + + $this->rebuildAll(); + } + +} diff --git a/tests/src/Kernel/TrashAccessCheckTest.php b/tests/src/Kernel/TrashAccessCheckTest.php new file mode 100644 index 0000000000000000000000000000000000000000..5aac5e50426830304828aede263942e774f88d81 --- /dev/null +++ b/tests/src/Kernel/TrashAccessCheckTest.php @@ -0,0 +1,350 @@ +<?php + +namespace Drupal\Tests\trash\Kernel; + +use Drupal\Core\Access\AccessResult; +use Drupal\Core\Access\AccessResultForbidden; +use Drupal\Core\Access\AccessResultInterface; +use Drupal\Core\Access\AccessResultReasonInterface; +use Drupal\Core\Routing\RouteMatch; +use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\user\Traits\UserCreationTrait; +use Drupal\entity_test\Entity\EntityTestMulBundle; +use Drupal\entity_test\Entity\EntityTestMulWithBundle; +use Drupal\language\Entity\ConfigurableLanguage; +use Drupal\language\Entity\ContentLanguageSettings; +use Drupal\trash\Access\TrashAccessCheck; +use Drupal\trash\TrashManagerInterface; +use Symfony\Component\Routing\Route; + +/** + * Tests TrashAccessCheck. + * + * @coversDefaultClass \Drupal\trash\Access\TrashAccessCheck + * @group trash + */ +class TrashAccessCheckTest extends KernelTestBase { + + use UserCreationTrait; + + /** + * The trash manager. + */ + protected TrashManagerInterface $trashManager; + + /** + * The access checker being tested. + */ + protected TrashAccessCheck $accessCheck; + + /** + * The entity type used for the tests. + */ + protected static string $entityTypeId = 'entity_test_mul_with_bundle'; + + /** + * The entity used for the tests. + */ + protected EntityTestMulWithBundle $entity; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'trash', + 'entity_test', + 'text', + 'language', + 'user', + 'system', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->installConfig(['system', 'user']); + $this->installEntitySchema('user'); + $this->installEntitySchema(static::$entityTypeId); + $this->installEntitySchema('entity_test_mul'); + + $this->trashManager = $this->container->get('trash.manager'); + $this->accessCheck = new TrashAccessCheck($this->container->get('entity_type.manager'), $this->container->get('language_manager'), $this->trashManager); + + ConfigurableLanguage::createFromLangcode('en')->save(); + ConfigurableLanguage::createFromLangcode('sv')->save(); + + EntityTestMulBundle::create([ + 'id' => 'default', + 'label' => 'Default', + ])->save(); + } + + /** + * Provides data for access check tests. + */ + public static function accessArgumentProvider(): array { + $entity_type_id = static::$entityTypeId; + // The default state has a single deleted entity with one translation. + // The default operation checked is "purge" and the current user has no + // permissions other than 'access content'. + return [ + 'Rejects disabled entity type' => [ + 'expected' => AccessResult::forbidden('Unsupported entity type'), + 'entity_values' => [ + 'type_enabled' => FALSE, + ], + ], + 'Rejects mismatched entity type' => [ + 'expected' => AccessResult::forbidden('Unsupported entity type'), + 'entity_values' => [ + 'route_entity_type_id' => 'entity_test_mul', + ], + ], + 'Rejects missing entity parameter' => [ + 'expected' => AccessResult::forbidden('No entity parameter'), + 'test_parameters' => [ + 'route_match_entity_exists' => FALSE, + ], + ], + 'Cannot restore the original language if not deleted' => [ + 'expected' => AccessResult::forbidden('Entity is not deleted'), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'operation' => 'restore', + ], + ], + 'Cannot purge the original language if not deleted' => [ + 'expected' => AccessResult::forbidden('Entity is not deleted'), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'operation' => 'purge', + ], + ], + 'Cannot restore a translation which is not deleted' => [ + 'expected' => AccessResult::forbidden('Entity is not deleted'), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'operation' => 'restore-translation', + 'language_parameter' => 'sv', + ], + ], + 'Cannot purge a translation which is not deleted' => [ + 'expected' => AccessResult::forbidden('Entity is not deleted'), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'operation' => 'purge-translation', + 'language_parameter' => 'sv', + ], + ], + 'Cannot purge-translation the original language' => [ + 'expected' => AccessResult::forbidden('Unsupported operation'), + 'test_parameters' => [ + 'operation' => 'purge-translation', + ], + ], + 'Cannot restore-translation the original language' => [ + 'expected' => AccessResult::forbidden('Unsupported operation'), + 'test_parameters' => [ + 'operation' => 'restore-translation', + ], + ], + 'Cannot purge an entity without permission' => [ + 'expected' => AccessResult::neutral("The 'purge {$entity_type_id} entities' permission is required."), + ], + 'Cannot restore an entity without permission' => [ + 'expected' => AccessResult::neutral("The 'purge {$entity_type_id} entities' permission is required."), + 'test_parameters' => [ + 'operation' => 'restore', + ], + ], + 'Can purge an entity with permission' => [ + 'expected' => AccessResult::allowed(), + 'test_parameters' => [ + 'user_permissions' => [ + "purge {$entity_type_id} entities", + ], + ], + ], + 'Can restore an entity with permission' => [ + 'expected' => AccessResult::allowed(), + 'test_parameters' => [ + 'operation' => 'restore', + 'user_permissions' => [ + "restore {$entity_type_id} entities", + ], + ], + ], + 'Can purge an entity translation with permission' => [ + 'expected' => AccessResult::allowed(), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'language_parameter' => 'sv', + 'translation_deleted' => TRUE, + 'operation' => 'purge-translation', + 'user_permissions' => [ + "purge {$entity_type_id} entities", + ], + ], + ], + 'Cannot restore an entity translation without editing permission' => [ + 'expected' => AccessResult::neutral("The 'administer entity_test content' permission is required."), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'language_parameter' => 'sv', + 'translation_deleted' => TRUE, + 'operation' => 'restore-translation', + 'user_permissions' => [ + // Only give restore access without editing access. + "restore {$entity_type_id} entities", + ], + ], + ], + 'Can restore an entity translation with editing permissions' => [ + 'expected' => AccessResult::allowed(), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'language_parameter' => 'sv', + 'translation_deleted' => TRUE, + 'operation' => 'restore-translation', + 'user_permissions' => [ + "restore {$entity_type_id} entities", + "administer entity_test content", + ], + ], + ], + 'Can purge an entity translation with translation permission, CT enabled' => [ + 'expected' => AccessResult::allowed(), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'language_parameter' => 'sv', + 'translation_deleted' => TRUE, + 'operation' => 'purge-translation', + 'enable_content_translation' => TRUE, + 'user_permissions' => [ + "purge {$entity_type_id} entities", + ], + ], + ], + 'Can restore an entity translation with translation permission, CT enabled' => [ + 'expected' => AccessResult::allowed(), + 'test_parameters' => [ + 'is_deleted' => FALSE, + 'language_parameter' => 'sv', + 'translation_deleted' => TRUE, + 'operation' => 'restore-translation', + 'enable_content_translation' => TRUE, + 'user_permissions' => [ + // Content Translation permissions. + 'translate any entity', + 'update content translations', + "restore {$entity_type_id} entities", + ], + ], + ], + ]; + } + + /** + * Tests the access checker. + * + * @dataProvider accessArgumentProvider + */ + public function testAccess( + AccessResultInterface $expected, + array $test_parameters = [], + ) { + + // Default values most tests will use. + $test_parameters += [ + // If the entity type is enabled in the TrashManager. + 'type_enabled' => TRUE, + // The language parameter in the URL. + 'language_parameter' => NULL, + // If the original language is deleted. + 'is_deleted' => TRUE, + // If the translation is deleted, is_deleted takes priority. + 'translation_deleted' => FALSE, + // The entity_type_id parameter passed in from the URL. + 'route_entity_type_id' => static::$entityTypeId, + // The operation set in the route's _trash_access requirement. + 'operation' => 'purge', + // Any permissions on the current user. Keys permissions, values toggle. + 'user_permissions' => [], + // If the entity was actually found by the entity_type_id route parameter. + 'route_match_entity_exists' => TRUE, + // If to enable content_translation module. + 'enable_content_translation' => FALSE, + ]; + // Add at least one permission so the user does not become an admin. + $test_parameters['user_permissions'][] = 'access content'; + + $route = new Route('/test_entity/{' . static::$entityTypeId . '}/{language}', [ + 'language' => NULL, + 'entity_type_id' => NULL, + ], [ + '_trash_access' => $test_parameters['operation'], + static::$entityTypeId => '\d+', + ]); + + if ($test_parameters['type_enabled']) { + $this->config('trash.settings')->set('enabled_entity_types', [static::$entityTypeId => []])->save(); + } + + if ($test_parameters['enable_content_translation']) { + $this->enableModules(['content_translation']); + ContentLanguageSettings::loadByEntityTypeBundle(static::$entityTypeId, 'default') + ->setDefaultLangcode('en') + ->setLanguageAlterable(TRUE) + ->setThirdPartySetting('content_translation', 'enabled', TRUE) + ->save(); + // Reload references from the new container. + $this->trashManager = $this->container->get('trash.manager'); + $this->accessCheck = new TrashAccessCheck($this->container->get('entity_type.manager'), $this->container->get('language_manager'), $this->trashManager); + } + + $this->entity = EntityTestMulWithBundle::create([ + 'name' => 'English', + 'type' => 'default', + 'language' => 'en', + ]); + $this->entity->addTranslation('sv', ['name' => 'Swedish translation']); + $this->entity->save(); + + if ($test_parameters['is_deleted']) { + $this->entity->delete(); + } + elseif ($test_parameters['translation_deleted']) { + $this->entity->removeTranslation('sv'); + $this->entity->save(); + } + + $user = $this->createUser($test_parameters['user_permissions'], 'Editor', FALSE, ['uid' => 2]); + $this->setCurrentUser($user); + + // Build arguments for a simulated request. + $language_parameter = $test_parameters['language_parameter'] ? $this->container->get('language_manager')->getLanguage($test_parameters['language_parameter']) : NULL; + $route_match_raw_parameters = [ + 'language' => $test_parameters['language_parameter'], + ]; + $route_match_parameters = [ + 'language' => $language_parameter, + ]; + if ($test_parameters['route_match_entity_exists']) { + $route_match_raw_parameters[static::$entityTypeId] = 1; + $route_match_parameters[static::$entityTypeId] = $this->entity; + } + $entity_type_id = static::$entityTypeId; + $route_match = new RouteMatch("entity.{$entity_type_id}.canonical", $route, $route_match_parameters, $route_match_raw_parameters); + + // Execute and verify expectations. + $result = $this->accessCheck->access($route, $route_match, $user, $language_parameter, $test_parameters['route_entity_type_id']); + $this->assertInstanceOf($expected::class, $result, $result instanceof AccessResultReasonInterface ? $result->getReason() : ''); + if ($expected instanceof AccessResultForbidden && $result instanceof AccessResultForbidden) { + $this->assertEquals($expected->getReason(), $result->getReason()); + } + } + +} diff --git a/tests/src/Kernel/TrashKernelTest.php b/tests/src/Kernel/TrashKernelTest.php index e9e25ac8b49065f8369e16d5d812dd9a06512574..bcb82072c1ac95ee8dd8709d0ef3a41d178d3eae 100644 --- a/tests/src/Kernel/TrashKernelTest.php +++ b/tests/src/Kernel/TrashKernelTest.php @@ -3,6 +3,7 @@ namespace Drupal\Tests\trash\Kernel; use Drupal\Core\Entity\ContentEntityInterface; +use Drupal\language\Entity\ConfigurableLanguage; use Drupal\node\Entity\Node; use Drupal\trash_test\Entity\TrashTestEntity; use Drupal\user\Entity\User; @@ -113,4 +114,111 @@ class TrashKernelTest extends TrashKernelTestBase { ]; } + /** + * Tests deleting and explicitly removing translations. + */ + public function testTranslationDeletion() { + $this->enableModules(['user', 'language']); + $this->installConfig(['user', 'language']); + $this->installEntitySchema('user'); + + ConfigurableLanguage::createFromLangcode('sv')->save(); + ConfigurableLanguage::createFromLangcode('de')->save(); + + $english = TrashTestEntity::create([ + 'label' => 'Test 1 EN', + ]); + $english->addTranslation('sv', [ + 'label' => 'Test 1 SV', + ]); + $english->addTranslation('de', [ + 'label' => 'Test 1 DE', + ]); + $english->save(); + $swedish = $english->getTranslation('sv'); + $german = $english->getTranslation('de'); + $this->assertEquals(0, $english->get('deleted')->value); + $this->assertEquals(0, $swedish->get('deleted')->value); + $this->assertEquals(0, $german->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $request_time = \Drupal::time()->getRequestTime(); + + // German deleted. English and Swedish automatically deleted. + $german->delete(); + $this->assertEquals($request_time, $english->get('deleted')->value); + $this->assertEquals($request_time, $german->get('deleted')->value); + $this->assertEquals($request_time, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + trash_restore_entity($english); + + // All restored. + $this->assertEquals(0, $english->get('deleted')->value); + $this->assertEquals(0, $german->get('deleted')->value); + $this->assertEquals(0, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + + // Swedish deleted. English and German automatically deleted. + $swedish->delete(); + $this->assertEquals($request_time, $english->get('deleted')->value); + $this->assertEquals($request_time, $german->get('deleted')->value); + $this->assertEquals($request_time, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + trash_restore_entity($english); + + // All restored. + $english->save(); + $this->assertEquals(0, $english->get('deleted')->value); + $this->assertEquals(0, $german->get('deleted')->value); + $this->assertEquals(0, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + + // Swedish removed. German and English available. + $english->removeTranslation('sv'); + $english->save(); + $this->assertEquals(0, $english->get('deleted')->value); + $this->assertEquals(0, $german->get('deleted')->value); + $this->assertEquals($request_time, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + + // German and Swedish removed. English available. + $english->removeTranslation('de'); + $english->save(); + $this->assertEquals(0, $english->get('deleted')->value); + $this->assertEquals($request_time, $german->get('deleted')->value); + $this->assertEquals($request_time, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + + // All deleted. + $english->delete(); + $this->assertEquals($request_time, $english->get('deleted')->value); + $this->assertEquals($request_time, $german->get('deleted')->value); + $this->assertEquals($request_time, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + + // English restored. German and Swedish deleted. + trash_restore_entity($english, ['en']); + $this->assertEquals(0, $english->get('deleted')->value); + $this->assertEquals($request_time, $german->get('deleted')->value); + $this->assertEquals($request_time, $swedish->get('deleted')->value); + $this->assertEquals('Test 1 EN', $english->label()); + $this->assertEquals('Test 1 DE', $german->label()); + $this->assertEquals('Test 1 SV', $swedish->label()); + } + } diff --git a/trash.install b/trash.install index 60a03703d08e7003dff331567d2a6bb57e44f2b5..592149de3c41c7eb305c9fef9a71525aa87baa9d 100644 --- a/trash.install +++ b/trash.install @@ -31,3 +31,16 @@ function trash_update_10301(): void { $config = \Drupal::configFactory()->getEditable('trash.settings'); $config->set('compact_overview', FALSE)->save(); } + +/** + * Add translation support to the Trash module. + */ +function trash_update_10302(): void { + $update_manager = \Drupal::entityDefinitionUpdateManager(); + $enabled_entity_types = \Drupal::service('trash.manager')->getEnabledEntityTypes(); + foreach ($enabled_entity_types as $entity_type_id) { + $field_storage_definition = $update_manager->getFieldStorageDefinition('deleted', $entity_type_id); + $field_storage_definition->setTranslatable(TRUE); + $update_manager->updateFieldStorageDefinition($field_storage_definition); + } +} diff --git a/trash.module b/trash.module index ac6770cf537274bc5d51fde0ea583e8af2f4e8bb..5267d9d260b5743a770fa712b9916dbf76614953 100644 --- a/trash.module +++ b/trash.module @@ -57,18 +57,20 @@ function trash_entity_is_deleted(EntityInterface $entity): bool { * * @param \Drupal\Core\Entity\EntityInterface $entity * An entity object. + * @param string[] $langcodes + * The translations to restore, defaults to all. * * @throws \Drupal\Core\Entity\EntityStorageException * In case of failures, an exception is thrown. */ -function trash_restore_entity(EntityInterface $entity): void { +function trash_restore_entity(EntityInterface $entity, array $langcodes = []): void { if (!\Drupal::service('trash.manager')->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle())) { return; } $storage = \Drupal::entityTypeManager()->getStorage($entity->getEntityTypeId()); // @phpstan-ignore-next-line - $storage->restoreFromTrash([$entity]); + $storage->restoreFromTrash([$entity], $langcodes); } /** @@ -81,7 +83,7 @@ function trash_entity_base_field_info(EntityTypeInterface $entity_type) { ->setLabel(t('Deleted')) ->setDescription(t('Time when the item got deleted')) ->setInternal(TRUE) - ->setTranslatable(FALSE) + ->setTranslatable(TRUE) ->setRevisionable(TRUE); return $base_field_definitions; @@ -92,6 +94,12 @@ function trash_entity_base_field_info(EntityTypeInterface $entity_type) { * Implements hook_entity_access(). */ function trash_entity_access(EntityInterface $entity, $operation, AccountInterface $account) { + /** @var \Drupal\trash\TrashManagerInterface $trash_manager */ + $trash_manager = \Drupal::service('trash.manager'); + if ($trash_manager->getTrashContext() === 'ignore') { + return AccessResult::neutral(); + } + $cacheability = new CacheableMetadata(); $cacheability->addCacheContexts(['user.permissions']); $cacheability->addCacheableDependency($entity); @@ -239,6 +247,8 @@ function _trash_generate_storage_class($original_class, $type = 'storage') { * Implements hook_entity_type_alter(). */ function trash_entity_type_alter(array &$entity_types) { + $is_multilingual = \Drupal::languageManager()->isMultilingual(); + /** @var \Drupal\Core\Entity\EntityTypeInterface[] $entity_types */ foreach ($entity_types as $entity_type_id => $entity_type) { if (\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type)) { @@ -258,12 +268,17 @@ function trash_entity_type_alter(array &$entity_types) { } $entity_type->setLinkTemplate('restore', $base_path . '/restore'); $entity_type->setLinkTemplate('purge', $base_path . '/purge'); - } - // Override node's access control handler, so we can skip the - // 'bypass node access' permission check if the node is deleted. - if ($entity_type->id() === 'node') { - $entity_type->setHandlerClass('access', TrashNodeAccessControlHandler::class); + if ($is_multilingual && $entity_type->isTranslatable()) { + $entity_type->setLinkTemplate('restore-translation', $base_path . '/restore/{language}'); + $entity_type->setLinkTemplate('purge-translation', $base_path . '/purge/{language}'); + } + + // Override node's access control handler, so we can skip the + // 'bypass node access' permission check if the node is deleted. + if ($entity_type->id() === 'node') { + $entity_type->setHandlerClass('access', TrashNodeAccessControlHandler::class); + } } } } @@ -345,41 +360,6 @@ function trash_form_alter(&$form, FormStateInterface $form_state, $form_id) { $entity_multiple_delete_label = t('Deleting these @label will move them to the trash.', $params); } - // Prevent deleting individual translations. - // @todo Remove this after https://www.drupal.org/i/3376216 is fixed. - if ($is_entity_delete_form && $entity instanceof TranslatableInterface && $entity->isTranslatable() && !$entity->isDefaultTranslation()) { - $entity_delete_label = t('Deleting a translation of a @label is currently not supported by Trash. Unpublish the translation instead.', $params); - $form['actions']['submit']['#access'] = FALSE; - } - elseif ($is_entity_multiple_delete_form && $entity_type->isTranslatable()) { - $storage = \Drupal::entityTypeManager()->getStorage($entity_type->id()); - - $can_delete = TRUE; - foreach (array_keys($form['entities']['#items'] ?? []) as $item) { - [$id, $langcode] = explode(':', $item, 2); - - // All entities have been loaded already in the static cache by the - // delete multiple form, so it's ok to single-load them again. - $entity = $storage->load($id); - assert($entity instanceof TranslatableInterface); - - // Deleting the default translation is considered the same as deleting - // the entire entity. When all translations are selected, only the - // default langcode will show up in the selections. - if (!$entity->getTranslation($langcode)->isDefaultTranslation()) { - // If any of the selected translations are not the default translation - // of the entity, the multiple deletion can not proceed. - $can_delete = FALSE; - break; - } - } - - if (!$can_delete) { - $entity_multiple_delete_label = t('Deleting translations of @label is currently not supported by Trash. Unpublish the translations instead.', $params); - $form['actions']['submit']['#access'] = FALSE; - } - } - $trash_handler = \Drupal::service('trash.manager')->getHandler($entity_type->id()); assert($trash_handler instanceof TrashHandlerInterface); if (isset($form['description']['#markup']) && $form['description']['#markup'] instanceof TranslatableMarkup) { @@ -412,6 +392,10 @@ function trash_entity_operation_alter(array &$operations, EntityInterface $entit return; } + $url_options = [ + 'language' => \Drupal::languageManager()->getCurrentLanguage(), + ]; + // Remove all other operations for deleted entities. $operations = []; if ($entity->access('restore')) { @@ -419,9 +403,18 @@ function trash_entity_operation_alter(array &$operations, EntityInterface $entit '@label' => $entity->label() ?? $entity->id(), ]); + if ($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation()) { + $restore_url = $entity->toUrl('restore-translation') + ->mergeOptions($url_options) + ->setRouteParameter('language', $entity->language()->getId()); + } + else { + $restore_url = $entity->toUrl('restore')->mergeOptions($url_options); + } + $operations['restore'] = [ 'title' => t('Restore'), - 'url' => $entity->toUrl('restore')->mergeOptions($url_options), + 'url' => $restore_url, 'weight' => 0, 'attributes' => [ 'class' => ['use-ajax'], @@ -437,9 +430,18 @@ function trash_entity_operation_alter(array &$operations, EntityInterface $entit '@label' => $entity->label() ?? $entity->id(), ]); + if ($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation()) { + $purge_url = $entity->toUrl('purge-translation') + ->mergeOptions($url_options) + ->setRouteParameter('language', $entity->language()->getId()); + } + else { + $purge_url = $entity->toUrl('purge')->mergeOptions($url_options); + } + $operations['purge'] = [ 'title' => t('Purge'), - 'url' => $entity->toUrl('purge')->mergeOptions($url_options), + 'url' => $purge_url, 'weight' => 5, 'attributes' => [ 'class' => ['use-ajax'], @@ -512,6 +514,22 @@ function trash_page_top(array &$page_top): void { } } +/** + * Implements hook_ENTITY_TYPE_insert(). + */ +function trash_configurable_language_insert(EntityInterface $entity) { + if (count(\Drupal::languageManager()->getLanguages()) === 2) { + // Need to add translation link templates. + /** @var \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager */ + $entity_type_manager = \Drupal::service('entity_type.manager'); + $entity_type_manager->clearCachedDefinitions(); + // Need to add the translation restore and purge routes. + /** @var \Drupal\Core\Routing\RouteBuilderInterface $router_builder */ + $router_builder = \Drupal::service('router.builder'); + $router_builder->setRebuildNeeded(); + } +} + /** * Implements hook_modules_installed(). */ diff --git a/trash.services.yml b/trash.services.yml index 79a2fff223f3da6be7687418846bf82565d5e0ad..6594f4d46c5fe44a65d5032e6288bf9072924d78 100644 --- a/trash.services.yml +++ b/trash.services.yml @@ -20,6 +20,12 @@ services: tags: - { name: event_subscriber } + trash.language_overview_subscriber: + class: Drupal\trash\EventSubscriber\TrashLanguageOverviewSubscriber + arguments: ['@entity_type.manager', '@trash.manager', '@language_manager'] + tags: + - { name: event_subscriber } + trash.ignore_subscriber: class: Drupal\trash\EventSubscriber\TrashIgnoreSubscriber arguments: ['@trash.manager', '@current_route_match'] @@ -28,10 +34,16 @@ services: trash.route_subscriber: class: Drupal\trash\Routing\RouteSubscriber - arguments: ['@entity_type.manager', '@trash.manager'] + arguments: ['@entity_type.manager', '@trash.manager', '@language_manager'] tags: - { name: event_subscriber } + trash.access_check: + class: Drupal\trash\Access\TrashAccessCheck + arguments: ['@entity_type.manager', '@language_manager', '@trash.manager'] + tags: + - { name: access_check, applies_to: _trash_access } + trash.route_processor: class: Drupal\trash\RouteProcessor\TrashRouteProcessor arguments: ['@request_stack', '@current_route_match']