diff --git a/composer.json b/composer.json index 6932f9c2bb9a7aeb122b807ddc5cdb14a9eb87bb..a37fef9e3ec59f8287eba99ebbaf0cf50582e686 100644 --- a/composer.json +++ b/composer.json @@ -3,11 +3,10 @@ "description": "This module adds a trash bin for all content entities.", "type": "drupal-module", "license": "GPL-2.0-or-later", - "extra": { - "drush": { - "services": { - "drush.services.yml": ">=9" - } - } + "suggest": { + "drush/drush": "^12 || ^13" + }, + "conflict": { + "drush/drush": "<12.5.1" } } diff --git a/config/install/trash.settings.yml b/config/install/trash.settings.yml index dbc36e88826bc26288d68ec624ce3fd31fd6c73a..4dc4a311b655bc2da715f11b45d56e0fe69bc121 100644 --- a/config/install/trash.settings.yml +++ b/config/install/trash.settings.yml @@ -1,5 +1,5 @@ -enabled_entity_types: - node: { } +enabled_entity_types: { } auto_purge: enabled: false after: '30 days' +compact_overview: false diff --git a/config/schema/trash.schema.yml b/config/schema/trash.schema.yml index 1466373e9474db7b18e19a3091af1ca704aff0f2..8c80f7b2369cd7c8bfba095cdf59277484fe0fbb 100644 --- a/config/schema/trash.schema.yml +++ b/config/schema/trash.schema.yml @@ -23,3 +23,6 @@ trash.settings: nullable: true constraints: ValidAutoPurgePeriod: [] + compact_overview: + label: 'Simplify the Trash overview page when there are many entity types enabled' + type: boolean diff --git a/css/trash.admin.css b/css/trash.admin.css new file mode 100644 index 0000000000000000000000000000000000000000..d803f9fb4f80af30cc7f0684a068eea6f78e5164 --- /dev/null +++ b/css/trash.admin.css @@ -0,0 +1,15 @@ +/** + * @file + * Visual styles for the Trash Admin UI. + */ + +/** + * Indent bundle selection. + */ +.trash--bundles.fieldset { + margin-left: 2em; /* LTR */ +} +[dir="rtl"] .trash--bundles.fieldset { + margin-right: 2em; + margin-left: 0; +} diff --git a/drush.services.yml b/drush.services.yml deleted file mode 100644 index fd803c2d5fd6e4e80cdedeb832d722de3f073f63..0000000000000000000000000000000000000000 --- a/drush.services.yml +++ /dev/null @@ -1,7 +0,0 @@ -services: - trash.commands: - class: Drupal\trash\Commands\TrashCommands - arguments: - - '@entity_type.manager' - - '@trash.manager' - tags: [{ name: drush.command }] diff --git a/js/trash.js b/js/trash.js new file mode 100644 index 0000000000000000000000000000000000000000..a86b5fb1f214780378d7d7a52623d1f498b24f53 --- /dev/null +++ b/js/trash.js @@ -0,0 +1,22 @@ +/** + * @file + * Javascript functionality for the Trash overview page. + */ + +(function (Drupal) { + /** + * Entity type selector on the Trash overview page. + */ + Drupal.behaviors.trashSelectEntityType = { + attach(context) { + once("trash-select-entity-type", ".trash-entity-type", context).forEach( + (trigger) => { + trigger.addEventListener('change', (e) => { + window.location = Drupal.url('admin/content/trash/' + e.target.value); + }); + }, + ); + }, + }; + +})(Drupal); diff --git a/phpstan.neon b/phpstan.neon index e8a1d7135359dd807f03f36bdd152cb4946fb966..01870d95f1dd366a1df2b358c8eecc6932eecc60 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -12,5 +12,9 @@ parameters: ignoreErrors: # new static() is a best practice in Drupal, so we cannot fix that. - "#^Unsafe usage of new static#" + # Ignore a missing event class until we can require Drupal 11.1. + - + message: '#^Parameter \$event of method Drupal\\trash\\EventSubscriber\\TrashIgnoreSubscriber\:\:onDefaultContentPreImport\(\) has invalid type Drupal\\Core\\DefaultContent\\PreImportEvent\.$#' + reportUnmatched: false scanDirectories: - ../../../../vendor/drush/drush/src-symfony-compatibility diff --git a/src/Commands/TrashCommands.php b/src/Commands/TrashCommands.php deleted file mode 100644 index 14af7e284300adcc9333787204b60d6d0efa0b7a..0000000000000000000000000000000000000000 --- a/src/Commands/TrashCommands.php +++ /dev/null @@ -1,75 +0,0 @@ -<?php - -namespace Drupal\trash\Commands; - -use Drupal\Core\Entity\EntityTypeManagerInterface; -use Drupal\Core\Site\Settings; -use Drupal\trash\TrashManagerInterface; -use Drush\Commands\DrushCommands; - -/** - * Drush commands for the Trash module. - */ -class TrashCommands extends DrushCommands { - - public function __construct( - protected EntityTypeManagerInterface $entityTypeManager, - protected TrashManagerInterface $trashManager, - ) { - parent::__construct(); - } - - /** - * Purges all trashed entities. - * - * @param string|null $entityTypeId - * The entity type to purge. - * - * @command trash:purge - * @aliases tp - */ - public function purge(?string $entityTypeId = NULL): void { - if (is_string($entityTypeId)) { - $entityTypeIds = [$entityTypeId]; - } - else { - $entityTypeIds = $this->trashManager->getEnabledEntityTypes(); - } - - $deleteCount = 0; - foreach ($entityTypeIds as $entityTypeId) { - $storage = $this->entityTypeManager->getStorage($entityTypeId); - $ids = $storage->getQuery() - ->accessCheck(FALSE) - ->addMetaData('trash', 'inactive') - ->exists('deleted') - ->execute(); - - if ($ids === []) { - continue; - } - - $this->io()->progressStart(count($ids)); - $chunkSize = Settings::get('entity_update_batch_size', 50); - - foreach (array_chunk($ids, $chunkSize) as $chunk) { - $this->trashManager->executeInTrashContext('inactive', function () use (&$deleteCount, $storage, $chunk) { - $entities = $storage->loadMultiple($chunk); - $storage->delete($entities); - $deleteCount += count($entities); - $this->io()->progressAdvance(count($chunk)); - }); - } - - $this->io()->progressFinish(); - } - - if ($deleteCount > 0) { - $this->io()->success(sprintf('Purged %d trashed entities.', $deleteCount)); - } - else { - $this->io()->success('No trashed entities to purge.'); - } - } - -} diff --git a/src/Controller/TrashController.php b/src/Controller/TrashController.php index 80070506f126ac32accef9e887c9126b2c66ed85..2489cf042cf533c25484f4b38c2f24ed61394361 100644 --- a/src/Controller/TrashController.php +++ b/src/Controller/TrashController.php @@ -1,10 +1,13 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Controller; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Datetime\DateFormatterInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; +use Drupal\Core\Entity\EntityListBuilder; use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeInterface; @@ -27,16 +30,6 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf */ protected bool $isMultilingual = FALSE; - /** - * Constructs the Trash controller. - * - * @param \Drupal\trash\TrashManagerInterface $trashManager - * The trash manager. - * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $bundleInfo - * The entity type bundle info service. - * @param \Drupal\Core\Datetime\DateFormatterInterface $dateFormatter - * The date formatter service. - */ public function __construct( protected TrashManagerInterface $trashManager, protected EntityTypeBundleInfoInterface $bundleInfo, @@ -48,7 +41,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('trash.manager'), $container->get('entity_type.bundle.info'), @@ -96,6 +89,22 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf * \Drupal\Core\Render\RendererInterface::render(). */ protected function render(string $entity_type_id): array { + $options = []; + foreach ($this->trashManager->getEnabledEntityTypes() as $id) { + $options[$id] = (string) $this->entityTypeManager()->getDefinition($id)->getLabel(); + } + $build['entity_type_id'] = [ + '#type' => 'select', + '#title' => $this->t('Entity type'), + '#options' => $options, + '#sort_options' => TRUE, + '#value' => $entity_type_id, + '#attributes' => [ + 'class' => ['trash-entity-type'], + ], + '#access' => (bool) $this->config('trash.settings')->get('compact_overview'), + ]; + $entity_type = $this->entityTypeManager()->getDefinition($entity_type_id); if ($this->isMultilingual && $entity_type->isTranslatable()) { $build['intro'] = [ @@ -145,6 +154,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf $build['pager'] = [ '#type' => 'pager', ]; + $build['#attached']['library'][] = 'trash/trash.admin'; return $build; } @@ -163,7 +173,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf $storage = $this->entityTypeManager()->getStorage($entity_type->id()); $entity_ids = $storage->getQuery() ->accessCheck(TRUE) - ->sort($entity_type->getKey('id')) + ->sort('deleted', 'DESC') ->pager(50) ->execute(); return $storage->loadMultiple($entity_ids); @@ -212,7 +222,7 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf */ protected function buildRow(FieldableEntityInterface $entity, array $url_options): array { $entity_type = $entity->getEntityType(); - if ($entity_type->getLinkTemplate('canonical') != $entity_type->getLinkTemplate('edit-form')) { + if ($entity_type->getLinkTemplate('canonical') != $entity_type->getLinkTemplate('edit-form') && $entity->access('view')) { $row['label']['data'] = [ '#type' => 'link', '#title' => $entity->label(), @@ -266,62 +276,20 @@ class TrashController extends ControllerBase implements ContainerInjectionInterf } } - $links = self::getOperations($entity, $url_options); + $list_builder = $this->entityTypeManager->hasHandler($entity_type->id(), 'list_builder') + ? $this->entityTypeManager->getListBuilder($entity_type->id()) + : $this->entityTypeManager->createHandlerInstance(EntityListBuilder::class, $entity_type); $row['operations']['data'] = [ '#type' => 'operations', - '#links' => $links, + '#links' => $list_builder->getOperations($entity) ?? [], + // Allow links to use modals. + '#attached' => [ + 'library' => ['core/drupal.dialog.ajax'], + ], ]; return $row; } - /** - * Get operation links for a deleted entity. - * - * @param \Drupal\Core\Entity\FieldableEntityInterface $entity - * An entity. - * @param array $url_options - * URL options such as language or query parameter. Defaults to adding the - * 'language' parameter from the current interface language. - * - * @return array{string: array{ - * title: \Drupal\Core\StringTranslation\TranslatableMarkup, - * url: \Drupal\Core\Url, - * weight: int - * }} - * An associative array with the purge & restore operation links. - */ - public static function getOperations(FieldableEntityInterface $entity, array $url_options = []): array { - if (!isset($url_options['language'])) { - $url_options['language'] = \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE); - } - if ($entity instanceof TranslatableInterface && !$entity->isDefaultTranslation()) { - $restore_route = $entity->toUrl('restore-translation', $url_options)->setRouteParameter('language', $entity->language()->getId()); - $purge_route = $entity->toUrl('purge-translation', $url_options)->setRouteParameter('language', $entity->language()->getId()); - } - else { - $restore_route = $entity->toUrl('restore', $url_options); - $purge_route = $entity->toUrl('purge', $url_options); - } - - $links = []; - // @todo Can remove access checks if #2473873 is committed. - if ($restore_route->access()) { - $links['restore'] = [ - 'title' => t('Restore'), - 'url' => $restore_route, - 'weight' => 0, - ]; - } - if ($purge_route->access()) { - $links['purge'] = [ - 'title' => t('Purge'), - 'url' => $purge_route, - 'weight' => 5, - ]; - } - return $links; - } - } diff --git a/src/Drush/Commands/TrashCommands.php b/src/Drush/Commands/TrashCommands.php new file mode 100644 index 0000000000000000000000000000000000000000..12cf3b64ac3b2090792556a1eb9b31d28a771dcd --- /dev/null +++ b/src/Drush/Commands/TrashCommands.php @@ -0,0 +1,223 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Drush\Commands; + +use Consolidation\AnnotatedCommand\Hooks\HookManager; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Site\Settings; +use Drupal\trash\TrashManagerInterface; +use Drush\Attributes as CLI; +use Drush\Commands\AutowireTrait; +use Drush\Commands\DrushCommands; +use Drush\Exceptions\CommandFailedException; +use Drush\Exceptions\UserAbortException; +use Drush\Utils\StringUtils; +use Symfony\Component\Console\Input\Input; + +/** + * Drush commands for the Trash module. + */ +final class TrashCommands extends DrushCommands { + + use AutowireTrait; + + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected TrashManagerInterface $trashManager, + ) { + parent::__construct(); + } + + /** + * Restores trashed entities. + */ + #[CLI\Command(name: 'trash:restore', aliases: ['tr'])] + #[CLI\Argument(name: 'entity_type_id', description: 'The entity type to restore.')] + #[CLI\Argument(name: 'entity_ids', description: 'A comma-separated list of entity IDs to restore.')] + #[CLI\Option(name: 'all', description: 'Restore data for all entity types.')] + public function restore(?string $entity_type_id = NULL, $entity_ids = NULL, array $options = ['all' => FALSE]): void { + $entity_ids = StringUtils::csvToArray($entity_ids); + $this->getConfirmation('restore', $entity_type_id, $entity_ids, $options); + + if ($options['all']) { + $entity_type_ids = $this->trashManager->getEnabledEntityTypes(); + $entity_ids = NULL; + } + else { + $entity_type_ids = [$entity_type_id]; + } + + $count = $this->performOperation('restore', $entity_type_ids, $entity_ids); + + if ($count > 0) { + $this->io()->success(dt('Restored @count trashed entities.', ['@count' => $count])); + } + else { + $this->io()->success(dt('No trashed entities to restore.')); + } + } + + /** + * Purges trashed entities. + */ + #[CLI\Command(name: 'trash:purge', aliases: ['tp'])] + #[CLI\Argument(name: 'entity_type_id', description: 'The entity type to purge.')] + #[CLI\Argument(name: 'entity_ids', description: 'A comma-separated list of entity IDs to purge.')] + #[CLI\Option(name: 'all', description: 'Purge data for all entity types.')] + public function purge(?string $entity_type_id = NULL, $entity_ids = NULL, array $options = ['all' => FALSE]): void { + $entity_ids = StringUtils::csvToArray($entity_ids); + $this->getConfirmation('purge', $entity_type_id, $entity_ids, $options); + + if ($options['all']) { + $entity_type_ids = $this->trashManager->getEnabledEntityTypes(); + $entity_ids = NULL; + } + else { + $entity_type_ids = [$entity_type_id]; + } + + $count = $this->performOperation('purge', $entity_type_ids, $entity_ids); + + if ($count > 0) { + $this->io()->success(dt('Purged @count trashed entities.', ['@count' => $count])); + } + else { + $this->io()->success(dt('No trashed entities to purge.')); + } + } + + /** + * Asks the user to select an entity type. + */ + #[CLI\Hook(type: HookManager::INTERACT, target: 'trash:restore')] + public function hookInteractRestore(Input $input): void { + if (!$input->getArgument('entity_type_id') && !$input->getOption('all')) { + $entity_type_ids = $this->trashManager->getEnabledEntityTypes(); + + if ($entity_type_ids !== []) { + if (!$choice = $this->io()->select('Select the entity type you want to restore.', array_combine($entity_type_ids, $entity_type_ids))) { + throw new UserAbortException(); + } + + $input->setArgument('entity_type_id', $choice); + } + else { + throw new CommandFailedException(dt('No entity types enabled.')); + } + } + } + + /** + * Asks the user to select an entity type. + */ + #[CLI\Hook(type: HookManager::INTERACT, target: 'trash:purge')] + public function hookInteractPurge(Input $input): void { + if (!$input->getArgument('entity_type_id') && !$input->getOption('all')) { + $entity_type_ids = $this->trashManager->getEnabledEntityTypes(); + + if ($entity_type_ids !== []) { + if (!$choice = $this->io()->select('Select the entity type you want to purge.', array_combine($entity_type_ids, $entity_type_ids))) { + throw new UserAbortException(); + } + + $input->setArgument('entity_type_id', $choice); + } + else { + throw new CommandFailedException(dt('No entity types enabled.')); + } + } + } + + /** + * Prompts the user to confirm the command arguments. + */ + protected function getConfirmation($operation, ?string $entity_type_id = NULL, ?array $entity_ids = NULL, array $options = ['all' => FALSE]): void { + if ($options['all']) { + if (!$this->io()->confirm(dt('Are you sure you want to @operation all data for all entity types?', [ + '@operation' => $operation, + ]))) { + throw new UserAbortException(); + } + } + else { + if (!$entity_ids) { + if (!$this->io()->confirm(dt('Are you sure you want to @operation all data for the @entity_type_id entity type?', [ + '@operation' => $operation, + '@entity_type_id' => $entity_type_id, + ]))) { + throw new UserAbortException(); + } + } + else { + if (!$this->io()->confirm(dt('Are you sure you want to @operation @entity_type_id @entity_ids?', [ + '@operation' => $operation, + '@entity_type_id' => $entity_type_id, + '@entity_ids' => implode(', ', $entity_ids), + ]))) { + throw new UserAbortException(); + } + } + } + } + + /** + * Performs a restore or purge operation on the given arguments. + * + * @param string $operation + * The operation to perform, either 'restore' or 'purge'. + * @param array $entity_type_ids + * An array of entity type IDs. + * @param array|null $entity_ids + * An array of entity IDs. + * + * @return int + * Returns the number of entities on which the operation was performed. + */ + protected function performOperation(string $operation, array $entity_type_ids, ?array $entity_ids = NULL): int { + assert(in_array($operation, ['restore', 'purge'], TRUE)); + + $count = 0; + foreach ($entity_type_ids as $entity_type_id) { + $storage = $this->entityTypeManager->getStorage($entity_type_id); + $query = $storage->getQuery() + ->accessCheck(FALSE) + ->addMetaData('trash', 'inactive') + ->exists('deleted'); + + if (count($entity_type_ids) === 1 && $entity_ids) { + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $query->condition($entity_type->getKey('id'), $entity_ids, 'IN'); + } + + $ids = $query->execute(); + if ($ids === []) { + continue; + } + + $this->io()->progressStart(count($ids)); + $chunkSize = Settings::get('entity_update_batch_size', 50); + + foreach (array_chunk($ids, $chunkSize) as $chunk) { + $this->trashManager->executeInTrashContext('inactive', function () use (&$count, $storage, $chunk, $operation) { + $entities = $storage->loadMultiple($chunk); + if ($operation === 'restore') { + // @phpstan-ignore-next-line + $storage->restoreFromTrash($entities); + } + elseif ($operation === 'purge') { + $storage->delete($entities); + } + $count += count($entities); + $this->io()->progressAdvance(count($chunk)); + }); + } + + $this->io()->progressFinish(); + } + + return $count; + } + +} diff --git a/src/EntityHandler/TrashNodeAccessControlHandler.php b/src/EntityHandler/TrashNodeAccessControlHandler.php index 70ee993ad3de7b71b91286cea9c4f57fc3fd8152..b7e46b2ead5de40c3e9e1c4b5442371a3ee102ad 100644 --- a/src/EntityHandler/TrashNodeAccessControlHandler.php +++ b/src/EntityHandler/TrashNodeAccessControlHandler.php @@ -2,7 +2,6 @@ namespace Drupal\trash\EntityHandler; -use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Session\AccountInterface; use Drupal\node\NodeAccessControlHandler; @@ -24,16 +23,15 @@ class TrashNodeAccessControlHandler extends NodeAccessControlHandler { // Invoke the trash access checker before the 'bypass node access' // permission is checked by the parent implementation. - $trash_access = trash_entity_access($entity, $operation, $account); - if ($trash_access->isForbidden()) { - return $return_as_object ? $trash_access : $trash_access->isAllowed(); - } + $return = trash_entity_access($entity, $operation, $account); - $result = parent::access($entity, $operation, $account, TRUE); - assert($result instanceof AccessResult); - $result->cachePerPermissions(); + // Also execute the default access check except when the access result is + // already forbidden, as in that case, it can not be anything else. + if (!$return->isForbidden()) { + $return = $return->orIf(parent::access($entity, $operation, $account, TRUE)); + } - return $return_as_object ? $result : $result->isAllowed(); + return $return_as_object ? $return : $return->isAllowed(); } } diff --git a/src/EventSubscriber/TrashConfigSubscriber.php b/src/EventSubscriber/TrashConfigSubscriber.php index 125534b46f20b0e4d66ebe1397c49d608cd0bd84..57839fcdf3e8dea8965ca35d96a3db3e14630d96 100644 --- a/src/EventSubscriber/TrashConfigSubscriber.php +++ b/src/EventSubscriber/TrashConfigSubscriber.php @@ -1,9 +1,12 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\EventSubscriber; use Drupal\Core\Config\ConfigCrudEvent; use Drupal\Core\Config\ConfigEvents; +use Drupal\Core\DrupalKernelInterface; use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Routing\RouteBuilderInterface; @@ -15,14 +18,12 @@ use Symfony\Component\EventDispatcher\EventSubscriberInterface; */ class TrashConfigSubscriber implements EventSubscriberInterface { - /** - * Constructor. - */ public function __construct( protected EntityTypeManagerInterface $entityTypeManager, protected TrashManagerInterface $trashManager, protected EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository, protected RouteBuilderInterface $routeBuilder, + protected DrupalKernelInterface $kernel, ) {} /** @@ -31,7 +32,7 @@ class TrashConfigSubscriber implements EventSubscriberInterface { * @param \Drupal\Core\Config\ConfigCrudEvent $event * The ConfigCrudEvent to process. */ - public function onSave(ConfigCrudEvent $event) { + public function onSave(ConfigCrudEvent $event): void { if ($event->getConfig()->getName() === 'trash.settings') { $supported_entity_types = array_filter($this->entityTypeManager->getDefinitions(), function ($entity_type) { return $this->trashManager->isEntityTypeSupported($entity_type); @@ -60,6 +61,11 @@ class TrashConfigSubscriber implements EventSubscriberInterface { // When an entity type is enabled or disabled, the router needs to be // rebuilt to add the corresponding tabs in the trash UI. $this->routeBuilder->setRebuildNeeded(); + + // The container also needs to be rebuilt in order to update the trash + // handler services. + // @see \Drupal\trash\Handler\TrashHandlerPass::process() + $this->kernel->invalidateContainer(); } } diff --git a/src/EventSubscriber/TrashEntitySchemaSubscriber.php b/src/EventSubscriber/TrashEntitySchemaSubscriber.php new file mode 100644 index 0000000000000000000000000000000000000000..3dff227dfec46b2f9bab05ea6eb6af836b2965ca --- /dev/null +++ b/src/EventSubscriber/TrashEntitySchemaSubscriber.php @@ -0,0 +1,46 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\EventSubscriber; + +use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Entity\EntityTypeEventSubscriberTrait; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeListenerInterface; +use Drupal\trash\TrashManagerInterface; +use Symfony\Component\EventDispatcher\EventSubscriberInterface; + +/** + * Defines a class for listening to entity schema changes. + */ +class TrashEntitySchemaSubscriber implements EntityTypeListenerInterface, EventSubscriberInterface { + + use EntityTypeEventSubscriberTrait; + + public function __construct( + protected TrashManagerInterface $trashManager, + protected ConfigFactoryInterface $configFactory, + ) {} + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents(): array { + return static::getEntityTypeEvents(); + } + + /** + * {@inheritdoc} + */ + public function onEntityTypeDelete(EntityTypeInterface $entity_type): void { + // Remove the deleted entity type from the Trash settings. + if ($this->trashManager->isEntityTypeEnabled($entity_type)) { + $trash_settings = $this->configFactory->getEditable('trash.settings'); + $enabled_entity_types = $trash_settings->get('enabled_entity_types'); + unset($enabled_entity_types[$entity_type->id()]); + $trash_settings->set('enabled_entity_types', $enabled_entity_types)->save(); + } + } + +} diff --git a/src/EventSubscriber/TrashIgnoreSubscriber.php b/src/EventSubscriber/TrashIgnoreSubscriber.php index 0534d610bcb7c2056980a58894bc5fc7ef9a6e79..6f53747fb091202edd03b322502d7c12f565260c 100644 --- a/src/EventSubscriber/TrashIgnoreSubscriber.php +++ b/src/EventSubscriber/TrashIgnoreSubscriber.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace Drupal\trash\EventSubscriber; +use Drupal\Core\DefaultContent\PreImportEvent; +use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Update\UpdateKernel; use Drupal\trash\TrashManagerInterface; use Drupal\workspaces\Event\WorkspacePostPublishEvent; @@ -18,31 +20,60 @@ use Symfony\Component\HttpKernel\KernelEvents; */ class TrashIgnoreSubscriber implements EventSubscriberInterface { - /** - * Constructor. - * - * @param \Drupal\trash\TrashManagerInterface $trashManager - * The trash manager. - */ public function __construct( protected TrashManagerInterface $trashManager, + protected RouteMatchInterface $routeMatch, ) {} /** - * Sets the trash context to ignore. + * Sets the trash context to ignore if needed. * - * This is required so upgrades affecting entities will affect all entities, - * no matter if they have been trashed. + * @param \Symfony\Component\HttpKernel\Event\KernelEvent $event + * The KernelEvent to process. + */ + public function onRequestPreRouting(KernelEvent $event): void { + if (!$event->isMainRequest()) { + return; + } + + // This is needed so upgrades affecting entities will affect all entities, + // no matter if they have been trashed. + $is_update_kernel = $event->getKernel() instanceof UpdateKernel; + + $has_trash_query = $event->getRequest()->query->has('in_trash'); + + if ($is_update_kernel || $has_trash_query) { + $this->trashManager->setTrashContext('ignore'); + } + } + + /** + * Sets the trash context to ignore if needed. * * @param \Symfony\Component\HttpKernel\Event\KernelEvent $event * The KernelEvent to process. */ public function onRequest(KernelEvent $event): void { - if ($event->getKernel() instanceof UpdateKernel) { + if (!$event->isMainRequest()) { + return; + } + + // Allow trashed entities to be displayed on the workspace manage page. + if ($this->routeMatch->getRouteName() === 'entity.workspace.canonical') { $this->trashManager->setTrashContext('ignore'); } } + /** + * Ignores the trash context when default_content imports content. + * + * @param \Drupal\Core\DefaultContent\PreImportEvent $event + * The default_content pre-import event. + */ + public function onDefaultContentPreImport(PreImportEvent $event): void { + $this->trashManager->setTrashContext('ignore'); + } + /** * Ignores the trash context when publishing a workspace. * @@ -67,12 +98,22 @@ class TrashIgnoreSubscriber implements EventSubscriberInterface { * {@inheritdoc} */ public static function getSubscribedEvents(): array { + // Our ignore subscriber needs to run before language negotiation (which has + // a priority of 255) in order to allow route enhancers (e.g. entity param + // converter) to load the deleted entity. + $events[KernelEvents::REQUEST][] = ['onRequestPreRouting', 256]; + + // Add another subscriber for setting the ignore trash context when the + // current route is known. $events[KernelEvents::REQUEST][] = ['onRequest']; if (class_exists(WorkspacePublishEvent::class)) { $events[WorkspacePrePublishEvent::class][] = ['onWorkspacePrePublish']; $events[WorkspacePostPublishEvent::class][] = ['onWorkspacePostPublish']; } + if (class_exists(PreImportEvent::class)) { + $events[PreImportEvent::class][] = ['onDefaultContentPreImport']; + } return $events; } diff --git a/src/Form/EntityPurgeForm.php b/src/Form/EntityPurgeForm.php index 7691a90fb47489380ae1dae861cd087fb8a10d93..fa0ba78dab3d6d298a882c481098816986fcb585 100644 --- a/src/Form/EntityPurgeForm.php +++ b/src/Form/EntityPurgeForm.php @@ -1,60 +1,34 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Form; -use Drupal\Component\Datetime\TimeInterface; -use Drupal\Core\Database\Connection; use Drupal\Core\Entity\ContentEntityConfirmFormBase; -use Drupal\Core\Entity\EntityRepositoryInterface; -use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Form\WorkspaceSafeFormInterface; use Drupal\Core\Url; -use Drupal\trash\TrashManagerInterface; +use Drupal\trash\TrashManager; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Provides a generic base class for a content entity purge form. */ -class EntityPurgeForm extends ContentEntityConfirmFormBase { +class EntityPurgeForm extends ContentEntityConfirmFormBase implements WorkspaceSafeFormInterface { /** - * Constructs a ContentEntityForm object. - * - * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository - * The entity repository service. - * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info - * The entity type bundle service. - * @param \Drupal\Component\Datetime\TimeInterface $time - * The time service. - * @param \Drupal\Core\Database\Connection $database - * The database connection. - * @param \Drupal\trash\TrashManagerInterface $trashManager - * The Trash Manager service. + * The trash manager. */ - public function __construct( - EntityRepositoryInterface $entity_repository, - EntityTypeBundleInfoInterface $entity_type_bundle_info, - TimeInterface $time, - protected Connection $database, - protected TrashManagerInterface $trashManager, - ) { - parent::__construct($entity_repository, $entity_type_bundle_info, $time); - // Ensure form altering modules like Workspaces can load unchanged entities. - $this->trashManager->setTrashContext('inactive'); - } + protected TrashManager $trashManager; /** * {@inheritdoc} */ public static function create(ContainerInterface $container) { - return new static( - $container->get('entity.repository'), - $container->get('entity_type.bundle.info'), - $container->get('datetime.time'), - $container->get('database'), - $container->get('trash.manager'), - ); + $instance = parent::create($container); + $instance->trashManager = $container->get('trash.manager'); + return $instance; } /** @@ -128,8 +102,7 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase { $form['actions']['submit']['#value'] = $this->t('Delete @language translation', ['@language' => $entity->language()->getName()]); } - // Trash operations are allowed in a workspace. - $form_state->set('workspace_safe', TRUE); + $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->purgeFormAlter($form, $form_state); return $form; } @@ -162,6 +135,12 @@ class EntityPurgeForm extends ContentEntityConfirmFormBase { $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'], + '%label' => $entity->label() ?? $entity->id(), + ]); } /** diff --git a/src/Form/EntityRestoreForm.php b/src/Form/EntityRestoreForm.php index ba23aef2975e4c4d658ff9b3170cf4842a40edf9..c722556631ea684f515bb49d49d98e7dc8e08bc5 100644 --- a/src/Form/EntityRestoreForm.php +++ b/src/Form/EntityRestoreForm.php @@ -1,60 +1,34 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Form; -use Drupal\Component\Datetime\TimeInterface; -use Drupal\Core\Database\Connection; use Drupal\Core\Entity\ContentEntityConfirmFormBase; -use Drupal\Core\Entity\EntityRepositoryInterface; -use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Form\WorkspaceSafeFormInterface; use Drupal\Core\Url; -use Drupal\trash\TrashManagerInterface; +use Drupal\trash\TrashManager; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Provides a generic base class for a content entity restore form. */ -class EntityRestoreForm extends ContentEntityConfirmFormBase { +class EntityRestoreForm extends ContentEntityConfirmFormBase implements WorkspaceSafeFormInterface { /** - * Constructs a ContentEntityForm object. - * - * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository - * The entity repository service. - * @param \Drupal\Core\Entity\EntityTypeBundleInfoInterface $entity_type_bundle_info - * The entity type bundle service. - * @param \Drupal\Component\Datetime\TimeInterface $time - * The time service. - * @param \Drupal\Core\Database\Connection $database - * The database connection. - * @param \Drupal\trash\TrashManagerInterface $trashManager - * The Trash Manager service. + * The trash manager. */ - public function __construct( - EntityRepositoryInterface $entity_repository, - EntityTypeBundleInfoInterface $entity_type_bundle_info, - TimeInterface $time, - protected Connection $database, - protected TrashManagerInterface $trashManager, - ) { - parent::__construct($entity_repository, $entity_type_bundle_info, $time); - // Ensure form altering modules like Workspaces can load unchanged entities. - $this->trashManager->setTrashContext('inactive'); - } + protected TrashManager $trashManager; /** * {@inheritdoc} */ public static function create(ContainerInterface $container) { - return new static( - $container->get('entity.repository'), - $container->get('entity_type.bundle.info'), - $container->get('datetime.time'), - $container->get('database'), - $container->get('trash.manager'), - ); + $instance = parent::create($container); + $instance->trashManager = $container->get('trash.manager'); + return $instance; } /** @@ -135,10 +109,9 @@ class EntityRestoreForm extends ContentEntityConfirmFormBase { } } - // Trash operations are allowed in a workspace. - $form_state->set('workspace_safe', TRUE); + $this->trashManager->getHandler($this->getEntity()->getEntityTypeId())?->restoreFormAlter($form, $form_state); - return parent::buildForm($form, $form_state); + return $form; } /** @@ -177,6 +150,13 @@ class EntityRestoreForm extends ContentEntityConfirmFormBase { 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'], + '%label' => $entity->label() ?? $entity->id(), + ]); } /** diff --git a/src/Form/TrashSettingsForm.php b/src/Form/TrashSettingsForm.php index 712621df59cde6af42e05c1ee3b69878b7069f2f..f9dd5a8ad359ca941fe73037aa6e7be562f23d1b 100644 --- a/src/Form/TrashSettingsForm.php +++ b/src/Form/TrashSettingsForm.php @@ -1,11 +1,17 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Form; +use Drupal\Core\Entity\EntityFieldManagerInterface; +use Drupal\Core\Entity\EntityTypeBundleInfoInterface; +use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\Sql\SqlEntityStorageInterface; use Drupal\Core\Form\ConfigFormBase; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\TypedData\TypedDataTrait; +use Drupal\Core\Url; use Symfony\Component\DependencyInjection\ContainerInterface; /** @@ -13,33 +19,25 @@ use Symfony\Component\DependencyInjection\ContainerInterface; */ class TrashSettingsForm extends ConfigFormBase { - use TypedDataTrait; - /** * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ - protected $entityTypeManager; + protected EntityTypeManagerInterface $entityTypeManager; /** * The entity field manager. - * - * @var \Drupal\Core\Entity\EntityFieldManagerInterface */ - protected $entityFieldManager; + protected EntityFieldManagerInterface $entityFieldManager; /** * The entity type bundle info. - * - * @var \Drupal\Core\Entity\EntityTypeBundleInfoInterface */ - protected $entityTypeBundleInfo; + protected EntityTypeBundleInfoInterface $entityTypeBundleInfo; /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { $instance = parent::create($container); $instance->entityTypeManager = $container->get('entity_type.manager'); @@ -69,6 +67,19 @@ class TrashSettingsForm extends ConfigFormBase { public function buildForm(array $form, FormStateInterface $form_state) { $config = $this->config('trash.settings'); $enabled_entity_types = $config->get('enabled_entity_types') ?? []; + $unsupported_entity_types = $this->getUnsupportedEntityTypes(); + + // Get all applicable entity types. + $applicable_entity_types = array_map( + fn (EntityTypeInterface $entity_type): string => (string) $entity_type->getLabel(), + array_filter( + $this->entityTypeManager->getDefinitions(), + fn (EntityTypeInterface $entity_type): bool => + is_subclass_of($entity_type->getStorageClass(), SqlEntityStorageInterface::class) + && !in_array($entity_type->id(), $unsupported_entity_types, TRUE), + ) + ); + asort($applicable_entity_types); $form['enabled_entity_types'] = [ '#type' => 'details', @@ -77,58 +88,36 @@ class TrashSettingsForm extends ConfigFormBase { '#tree' => TRUE, ]; - // Disallow enabling trash on entity types that haven't been tested enough. - $disallowed_entity_types = [ - 'block_content', - 'comment', - 'taxonomy_term', - 'path_alias', - 'user', - 'workspace', - 'wse_menu_tree', - ]; - - if ($this->entityTypeManager->hasDefinition('menu_link_content')) { - // Custom menu links can be deleted if there's a module which allows - // changing the hierarchy in pending revisions (e.g. wse_menu). - $menu_link_content = $this->entityTypeManager->getDefinition('menu_link_content'); - $constraints = $menu_link_content->getConstraints(); - if (isset($constraints['MenuTreeHierarchy'])) { - $disallowed_entity_types = array_merge($disallowed_entity_types, ['menu_link_content']); - } - } - // Get all applicable entity types. - foreach ($this->entityTypeManager->getDefinitions() as $entity_type_id => $entity_type) { - if (is_subclass_of($entity_type->getStorageClass(), SqlEntityStorageInterface::class) - && !\in_array($entity_type_id, $disallowed_entity_types, TRUE)) { - /** @var \Drupal\Core\Field\BaseFieldDefinition[] $field_definitions */ - $field_definitions = $this->entityFieldManager->getBaseFieldDefinitions($entity_type_id); - $form['enabled_entity_types'][$entity_type_id]['enabled'] = [ - '#type' => 'checkbox', - '#title' => $entity_type->getLabel(), - '#default_value' => isset($field_definitions['deleted']) && isset($enabled_entity_types[$entity_type_id]), - '#disabled' => isset($field_definitions['deleted']) && ($field_definitions['deleted']->getProvider() !== 'trash'), - ]; - if ($entity_type->getBundleEntityType()) { - $bundles = array_map( - fn (array $bundle): string => $bundle['label'], - $this->entityTypeBundleInfo->getBundleInfo($entity_type_id) - ); - asort($bundles); - - $form['enabled_entity_types'][$entity_type_id]['bundles'] = [ - '#type' => 'checkboxes', - '#title' => $this->t('Bundles'), - '#description' => $this->t('If none are selected, all are allowed.'), - '#options' => $bundles, - '#default_value' => $enabled_entity_types[$entity_type_id] ?? [], - '#states' => [ - 'visible' => [ - ':input[name="enabled_entity_types[' . $entity_type_id . '][enabled]"]' => ['checked' => TRUE], - ], + foreach ($applicable_entity_types as $entity_type_id => $entity_type_label) { + /** @var \Drupal\Core\Field\BaseFieldDefinition[] $field_definitions */ + $field_definitions = $this->entityFieldManager->getBaseFieldDefinitions($entity_type_id); + $form['enabled_entity_types'][$entity_type_id]['enabled'] = [ + '#type' => 'checkbox', + '#title' => $entity_type_label, + '#default_value' => isset($field_definitions['deleted']) && isset($enabled_entity_types[$entity_type_id]), + '#disabled' => isset($field_definitions['deleted']) && ($field_definitions['deleted']->getProvider() !== 'trash'), + ]; + + $bundles = array_map( + fn (array $bundle): string => (string) $bundle['label'], + $this->entityTypeBundleInfo->getBundleInfo($entity_type_id) + ); + + if (count($bundles) > 1) { + asort($bundles); + $form['enabled_entity_types'][$entity_type_id]['bundles'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Bundles'), + '#description' => $this->t('If none are selected, all are allowed.'), + '#options' => $bundles, + '#default_value' => $enabled_entity_types[$entity_type_id] ?? [], + '#states' => [ + 'visible' => [ + ':input[name="enabled_entity_types[' . $entity_type_id . '][enabled]"]' => ['checked' => TRUE], ], - ]; - } + ], + '#attributes' => ['class' => ['trash--bundles']], + ]; } } @@ -154,12 +143,41 @@ class TrashSettingsForm extends ConfigFormBase { 'visible' => [ ':input[name="auto_purge[enabled]"]' => ['checked' => TRUE], ], + 'required' => [ + ':input[name="auto_purge[enabled]"]' => ['checked' => TRUE], + ], ], ]; + $form['compact_overview'] = [ + '#type' => 'checkbox', + '#title' => $this->t('Compact overview'), + '#config_target' => 'trash.settings:compact_overview', + '#description' => $this->t('Simplify the <a href=":url">Trash overview page</a> when there are many entity types enabled.', [ + ':url' => Url::fromRoute('trash.admin_content_trash')->toString(), + ]), + ]; + + $form['#attached']['library'][] = 'trash/trash.admin'; + return parent::buildForm($form, $form_state); } + /** + * {@inheritdoc} + */ + public function validateForm(array &$form, FormStateInterface $form_state) { + parent::validateForm($form, $form_state); + + $auto_purge = $form_state->getValue('auto_purge'); + if (!empty($auto_purge['enabled']) && empty($auto_purge['after'])) { + $form_state->setErrorByName('auto_purge][after', $this->t('Auto-purge time period is required.')); + } + elseif (empty($auto_purge['enabled'])) { + $form_state->unsetValue(['auto_purge', 'after']); + } + } + /** * {@inheritdoc} */ @@ -189,4 +207,30 @@ class TrashSettingsForm extends ConfigFormBase { parent::submitForm($form, $form_state); } + /** + * Returns an array of entity types that are not supported by Trash. + */ + protected function getUnsupportedEntityTypes(): array { + // Disallow enabling trash on entity types that haven't been tested enough. + $unsupported_entity_types = [ + 'comment', + 'taxonomy_term', + 'path_alias', + 'user', + 'workspace', + ]; + + if ($this->entityTypeManager->hasDefinition('menu_link_content')) { + // Custom menu links can be deleted if there's a module which allows + // changing the hierarchy in pending revisions (e.g. wse_menu). + $menu_link_content = $this->entityTypeManager->getDefinition('menu_link_content'); + $constraints = $menu_link_content->getConstraints(); + if (isset($constraints['MenuTreeHierarchy'])) { + $unsupported_entity_types = array_merge($unsupported_entity_types, ['menu_link_content']); + } + } + + return $unsupported_entity_types; + } + } diff --git a/src/Handler/DefaultTrashHandler.php b/src/Handler/DefaultTrashHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..1d5607c8307ae4a5c66ba05c4c79526e3c0120ea --- /dev/null +++ b/src/Handler/DefaultTrashHandler.php @@ -0,0 +1,94 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Handler; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\Entity\EntityInterface; +use Drupal\trash\TrashManagerInterface; + +/** + * Provides the default trash handler. + */ +class DefaultTrashHandler implements TrashHandlerInterface { + + use StringTranslationTrait; + + /** + * The ID of the entity type managed by this handler. + */ + protected string $entityTypeId; + + /** + * The entity type manager. + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * The trash manager. + */ + protected TrashManagerInterface $trashManager; + + /** + * {@inheritdoc} + */ + public function preTrashDelete(EntityInterface $entity): void {} + + /** + * {@inheritdoc} + */ + public function postTrashDelete(EntityInterface $entity): void {} + + /** + * {@inheritdoc} + */ + public function preTrashRestore(EntityInterface $entity): void {} + + /** + * {@inheritdoc} + */ + public function postTrashRestore(EntityInterface $entity): void {} + + /** + * {@inheritdoc} + */ + public function deleteFormAlter(array &$form, FormStateInterface $form_state, bool $multiple = FALSE): void {} + + /** + * {@inheritdoc} + */ + public function restoreFormAlter(array &$form, FormStateInterface $form_state): void {} + + /** + * {@inheritdoc} + */ + public function purgeFormAlter(array &$form, FormStateInterface $form_state): void {} + + /** + * {@inheritdoc} + */ + public function setEntityTypeId(string $entity_type_id): static { + $this->entityTypeId = $entity_type_id; + return $this; + } + + /** + * {@inheritdoc} + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager): static { + $this->entityTypeManager = $entity_type_manager; + return $this; + } + + /** + * {@inheritdoc} + */ + public function setTrashManager(TrashManagerInterface $trash_manager): static { + $this->trashManager = $trash_manager; + return $this; + } + +} diff --git a/src/Handler/TrashHandlerConfigurator.php b/src/Handler/TrashHandlerConfigurator.php new file mode 100644 index 0000000000000000000000000000000000000000..a683fe77b6b35a66d60a7a9aa79ba4cd56bb93ae --- /dev/null +++ b/src/Handler/TrashHandlerConfigurator.php @@ -0,0 +1,31 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Handler; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\trash\TrashManagerInterface; + +/** + * Provides a configurator for trash handler services. + */ +class TrashHandlerConfigurator { + + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected TrashManagerInterface $trashManager, + ) {} + + /** + * Configures a trash handler service. + * + * @param \Drupal\trash\Handler\TrashHandlerInterface $trashHandler + * A trash handler service. + */ + public function __invoke(TrashHandlerInterface $trashHandler): void { + $trashHandler->setEntityTypeManager($this->entityTypeManager); + $trashHandler->setTrashManager($this->trashManager); + } + +} diff --git a/src/Handler/TrashHandlerInterface.php b/src/Handler/TrashHandlerInterface.php new file mode 100644 index 0000000000000000000000000000000000000000..83b023de2c9efece66f0f165ecf1aacc4db1bb43 --- /dev/null +++ b/src/Handler/TrashHandlerInterface.php @@ -0,0 +1,97 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Handler; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Form\FormStateInterface; +use Drupal\trash\TrashManagerInterface; + +/** + * Provides an interface for trash handlers. + */ +interface TrashHandlerInterface { + + /** + * Acts before an entity is soft-deleted. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * An entity object. + */ + public function preTrashDelete(EntityInterface $entity): void; + + /** + * Acts after an entity is soft-deleted. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * An entity object. + */ + public function postTrashDelete(EntityInterface $entity): void; + + /** + * Acts before an entity is restored. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * An entity object. + */ + public function preTrashRestore(EntityInterface $entity): void; + + /** + * Acts after an entity is restored. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * An entity object. + */ + public function postTrashRestore(EntityInterface $entity): void; + + /** + * Alters the entity delete form to provide additional information if needed. + * + * @param array $form + * The entity form to be altered. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * @param bool $multiple + * (optional) Whether multiple entities are being deleted by the form. + * Defaults to FALSE. + */ + public function deleteFormAlter(array &$form, FormStateInterface $form_state, bool $multiple = FALSE): void; + + /** + * Alters the entity restore form to provide additional information if needed. + * + * @param array $form + * The entity form to be altered. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function restoreFormAlter(array &$form, FormStateInterface $form_state): void; + + /** + * Alters the entity purge form to provide additional information if needed. + * + * @param array $form + * The entity form to be altered. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + */ + public function purgeFormAlter(array &$form, FormStateInterface $form_state): void; + + /** + * Sets the ID of the entity type managed by this handler. + */ + public function setEntityTypeId(string $entity_type_id): static; + + /** + * Sets the entity type manager service. + */ + public function setEntityTypeManager(EntityTypeManagerInterface $entity_type_manager): static; + + /** + * Sets the trash manager service. + */ + public function setTrashManager(TrashManagerInterface $trash_manager): static; + +} diff --git a/src/Handler/TrashHandlerPass.php b/src/Handler/TrashHandlerPass.php new file mode 100644 index 0000000000000000000000000000000000000000..c19a8ca06c4859a32099593e6922f13b08fb9f00 --- /dev/null +++ b/src/Handler/TrashHandlerPass.php @@ -0,0 +1,59 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Handler; + +use Drupal\Core\Config\BootstrapConfigStorageFactory; +use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; +use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; + +/** + * Defines a compiler pass to register and configure trash handlers. + */ +class TrashHandlerPass implements CompilerPassInterface { + + /** + * {@inheritdoc} + */ + public function process(ContainerBuilder $container): void { + // The config factory might not be ready yet, so we bypass the container by + // using the bootstrap factory. + $config_storage = BootstrapConfigStorageFactory::get(); + // This config ignores overrides, so trash config from settings.php won't be + // taken into account. + /** @var \Drupal\Core\Config\ImmutableConfig $trash_settings */ + $trash_settings = $config_storage->read('trash.settings'); + + $enabled_entity_types = $trash_settings['enabled_entity_types'] ?? []; + $trash_handlers = $container->findTaggedServiceIds('trash_handler'); + + foreach ($trash_handlers as $id => $attributes) { + $entity_type_id = $attributes[0]['entity_type_id']; + + // Remove trash handlers for entity types that aren't enabled. + if (!isset($enabled_entity_types[$entity_type_id])) { + $container->removeDefinition($id); + } + else { + // Keep track of entity types without a dedicated trash handler so we + // can create one for them automatically. + unset($enabled_entity_types[$entity_type_id]); + + $container->getDefinition($id) + ->addMethodCall('setEntityTypeId', [$entity_type_id]) + ->setConfigurator(new Reference('trash.handler_configurator')); + } + } + + // Register a trash handler for entity types without a dedicated one. + foreach (array_keys($enabled_entity_types) as $entity_type_id) { + $container->register('trash.default_handler.' . $entity_type_id, DefaultTrashHandler::class) + ->addTag('trash_handler', ['entity_type_id' => $entity_type_id]) + ->addMethodCall('setEntityTypeId', [$entity_type_id]) + ->setConfigurator(new Reference('trash.handler_configurator')); + } + } + +} diff --git a/src/Hook/TrashHandler/MenuLinkContentTrashHandler.php b/src/Hook/TrashHandler/MenuLinkContentTrashHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..281e8a28ed04f9b971627a14d05dd29ad8278ec8 --- /dev/null +++ b/src/Hook/TrashHandler/MenuLinkContentTrashHandler.php @@ -0,0 +1,40 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Hook\TrashHandler; + +use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\Core\Menu\MenuLinkManagerInterface; +use Drupal\menu_link_content\MenuLinkContentInterface; +use Drupal\trash\Handler\DefaultTrashHandler; + +/** + * Provides a trash handler for the 'menu_link_content' entity type. + */ +class MenuLinkContentTrashHandler extends DefaultTrashHandler { + + public function __construct( + protected MenuLinkManagerInterface $menuLinkManager, + ) {} + + /** + * Implements hook_ENTITY_TYPE_update() for 'menu_link_content'. + */ + #[Hook('menu_link_content_update')] + public function entityUpdate(EntityInterface $entity): void { + assert($entity instanceof MenuLinkContentInterface); + + // Handle removing menu link definitions. It's essential that this is done + // in an update hook rather than a presave one because it needs to run after + // \Drupal\menu_link_content\Entity\MenuLinkContent::postSave(). That method + // might add a deleted menu link to the Live menu tree when a workspace is + // published, so this code needs to run afterward in order to remove it + // again. + if (trash_entity_is_deleted($entity)) { + $this->menuLinkManager->removeDefinition($entity->getPluginId(), FALSE); + } + } + +} diff --git a/src/Hook/TrashHandler/NodeTrashHandler.php b/src/Hook/TrashHandler/NodeTrashHandler.php new file mode 100644 index 0000000000000000000000000000000000000000..ee3a333361d794b5eafd6c34373f2cb1f557ea8e --- /dev/null +++ b/src/Hook/TrashHandler/NodeTrashHandler.php @@ -0,0 +1,28 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\Hook\TrashHandler; + +use Drupal\Core\Database\Query\AlterableInterface; +use Drupal\Core\Hook\Attribute\Hook; +use Drupal\trash\Handler\DefaultTrashHandler; + +/** + * Provides a trash handler for the 'node' entity type. + */ +class NodeTrashHandler extends DefaultTrashHandler { + + /** + * Implements hook_query_TAG_alter() for the 'search_node_search' tag. + */ + #[Hook('query_search_node_search_alter')] + public function querySearchNodeSearchAlter(AlterableInterface $query): void { + // The core Search module is not using an entity query, so we need to alter + // its query manually. + // @see \Drupal\node\Plugin\Search\NodeSearch::findResults() + /** @var \Drupal\Core\Database\Query\SelectInterface $query */ + $query->isNull('n.deleted'); + } + +} diff --git a/src/LayoutBuilder/TrashInlineBlockUsage.php b/src/LayoutBuilder/TrashInlineBlockUsage.php new file mode 100644 index 0000000000000000000000000000000000000000..2a89148b75e79042eba046c84424cd6c4ca7b7e7 --- /dev/null +++ b/src/LayoutBuilder/TrashInlineBlockUsage.php @@ -0,0 +1,75 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\trash\LayoutBuilder; + +use Drupal\block_content\Entity\BlockContent; +use Drupal\Core\Entity\EntityInterface; +use Drupal\layout_builder\InlineBlockUsageInterface; +use Drupal\trash\TrashManagerInterface; + +/** + * Decorates Layout Builder's inline block usage service. + */ +class TrashInlineBlockUsage implements InlineBlockUsageInterface { + + public function __construct( + protected InlineBlockUsageInterface $inner, + protected TrashManagerInterface $trashManager, + ) {} + + /** + * {@inheritdoc} + */ + public function addUsage($block_content_id, EntityInterface $entity) { + $this->inner->addUsage($block_content_id, $entity); + } + + /** + * {@inheritdoc} + */ + public function getUnused($limit = 100) { + return $this->inner->getUnused($limit); + } + + /** + * {@inheritdoc} + */ + public function removeByLayoutEntity(EntityInterface $entity) { + $this->inner->removeByLayoutEntity($entity); + } + + /** + * {@inheritdoc} + */ + public function deleteUsage(array $block_content_ids) { + // Inline blocks are (permanently) deleted when they are no longer used in + // any parent/referencing entity. With Trash, this can only happen when the + // parent entity is purged, which means we have to ensure that its inline + // blocks are purged as well instead of being trashed (soft-deleted). + // Layout Builders handles this in + // InlineBlockEntityOperations::deleteBlocksAndUsage(), but that method is + // not public API, and it could be changed at any time, so the only good + // option for Trash is to handle it in this service decorator. + if ($this->trashManager->isEntityTypeEnabled('block_content')) { + $this->trashManager->executeInTrashContext('inactive', function () use ($block_content_ids) { + foreach ($block_content_ids as $block_content_id) { + if ($block = BlockContent::load($block_content_id)) { + $block->delete(); + } + } + }); + } + + $this->inner->deleteUsage($block_content_ids); + } + + /** + * {@inheritdoc} + */ + public function getUsage($block_content_id) { + return $this->inner->getUsage($block_content_id); + } + +} diff --git a/src/Plugin/Derivative/TrashLocalTasks.php b/src/Plugin/Derivative/TrashLocalTasks.php index 79e31184a334d2e53a9d03e35b0267a2a7e0fef4..a65331f5d2466d93a3564ba39449ee59c703861c 100644 --- a/src/Plugin/Derivative/TrashLocalTasks.php +++ b/src/Plugin/Derivative/TrashLocalTasks.php @@ -1,8 +1,11 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Plugin\Derivative; use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Plugin\Discovery\ContainerDeriverInterface; use Drupal\trash\TrashManagerInterface; @@ -13,32 +16,11 @@ use Symfony\Component\DependencyInjection\ContainerInterface; */ class TrashLocalTasks extends DeriverBase implements ContainerDeriverInterface { - /** - * The entity type manager. - * - * @var \Drupal\Core\Entity\EntityTypeManagerInterface - */ - protected $entityTypeManager; - - /** - * The trash manager. - * - * @var \Drupal\trash\TrashManagerInterface - */ - protected $trashManager; - - /** - * Creates a TrashLocalTasks object. - * - * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager - * The entity type manager. - * @param \Drupal\trash\TrashManagerInterface $trash_manager - * The trash manager. - */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, TrashManagerInterface $trash_manager) { - $this->entityTypeManager = $entity_type_manager; - $this->trashManager = $trash_manager; - } + public function __construct( + protected EntityTypeManagerInterface $entityTypeManager, + protected TrashManagerInterface $trashManager, + protected ConfigFactoryInterface $configFactory, + ) {} /** * {@inheritdoc} @@ -46,7 +28,8 @@ class TrashLocalTasks extends DeriverBase implements ContainerDeriverInterface { public static function create(ContainerInterface $container, $base_plugin_id) { return new static( $container->get('entity_type.manager'), - $container->get('trash.manager') + $container->get('trash.manager'), + $container->get('config.factory') ); } @@ -54,6 +37,10 @@ class TrashLocalTasks extends DeriverBase implements ContainerDeriverInterface { * {@inheritdoc} */ public function getDerivativeDefinitions($base_plugin_definition) { + if ($this->configFactory->get('trash.settings')->get('compact_overview')) { + return ['trash' => $base_plugin_definition]; + } + $this->derivatives = []; $enabled_entity_types = $this->trashManager->getEnabledEntityTypes(); diff --git a/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php b/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php index 220447c2bdd9e15d3a742d2cb2c2c707a0ffbc85..cfc6163004918dfd3eff80f7281024bbe96819c8 100644 --- a/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php +++ b/src/Plugin/QueueWorker/TrashEntityPurgeWorker.php @@ -1,25 +1,28 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Plugin\QueueWorker; use Drupal\Component\Datetime\TimeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\Core\Plugin\ContainerFactoryPluginInterface; +use Drupal\Core\Queue\Attribute\QueueWorker; use Drupal\Core\Queue\QueueWorkerBase; use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\trash\TrashManagerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * A queue worker for purging the trash bin. - * - * @QueueWorker( - * id = "trash_entity_purge", - * title = @Translation("Trash Entity Purge Worker"), - * cron = {"time" = 60} - * ) */ +#[QueueWorker( + id: 'trash_entity_purge', + title: new TranslatableMarkup('Trash Entity Purge Worker'), + cron: ['time' => 60] +)] class TrashEntityPurgeWorker extends QueueWorkerBase implements ContainerFactoryPluginInterface { use StringTranslationTrait; diff --git a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php index 4ba75c2501b0eecdb8d8b3e802b39136f08976ab..6956d82e6edaa8bd6e529f2eb606c4f41581f05e 100644 --- a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php +++ b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraint.php @@ -4,22 +4,21 @@ declare(strict_types=1); namespace Drupal\trash\Plugin\Validation\Constraint; -use Symfony\Component\Validator\Constraint; +use Drupal\Core\StringTranslation\TranslatableMarkup; +use Drupal\Core\Validation\Attribute\Constraint; +use Symfony\Component\Validator\Constraint as SymfonyConstraint; /** * Checks that the value is a valid auto-purge period when calling strtotime(). - * - * @Constraint( - * id = "ValidAutoPurgePeriod", - * label = @Translation("Auto-purge period", context = "Validation") - * ) */ -class ValidAutoPurgePeriodConstraint extends Constraint { +#[Constraint( + id: 'ValidAutoPurgePeriod', + label: new TranslatableMarkup('Auto-purge period', [], ['context' => 'Validation']) +)] +class ValidAutoPurgePeriodConstraint extends SymfonyConstraint { /** * The error message. - * - * @var string */ public string $message = "The time period '@value' is not valid. Some valid values would be '1 month, 10 days', '15 days', '3 hours, 15 minutes'."; diff --git a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php index 9a3f16c57cb579b47944285245e57ba0d9d88733..d63186bdd7e58165251529a043f5fbaa41fd3c38 100644 --- a/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php +++ b/src/Plugin/Validation/Constraint/ValidAutoPurgePeriodConstraintValidator.php @@ -22,7 +22,7 @@ class ValidAutoPurgePeriodConstraintValidator extends ConstraintValidator implem /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('datetime.time'), ); @@ -32,6 +32,10 @@ class ValidAutoPurgePeriodConstraintValidator extends ConstraintValidator implem * {@inheritdoc} */ public function validate(mixed $value, Constraint $constraint): void { + if (empty($value)) { + return; + } + assert($constraint instanceof ValidAutoPurgePeriodConstraint); $timestamp = strtotime(sprintf("-%s", $value)); if (!$timestamp || $timestamp >= $this->time->getCurrentTime()) { diff --git a/src/Plugin/search_api/processor/TrashStatus.php b/src/Plugin/search_api/processor/TrashStatus.php index 695a299aa570ad1ee0f46ad69fd2e71c8550fb7b..9d0b5f59e7738a25ea2d1222f97f42aa42d83123 100644 --- a/src/Plugin/search_api/processor/TrashStatus.php +++ b/src/Plugin/search_api/processor/TrashStatus.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\Plugin\search_api\processor; use Drupal\Core\Entity\EntityInterface; diff --git a/src/RouteProcessor/TrashRouteProcessor.php b/src/RouteProcessor/TrashRouteProcessor.php index a6cc3cfea541be030d10fc21cf0198afa902486a..45a5dd3c55c44e2847c43f76c2c82a9f0b3536d9 100644 --- a/src/RouteProcessor/TrashRouteProcessor.php +++ b/src/RouteProcessor/TrashRouteProcessor.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash\RouteProcessor; use Drupal\Core\Render\BubbleableMetadata; @@ -21,12 +23,24 @@ class TrashRouteProcessor implements OutboundRouteProcessorInterface { /** * {@inheritdoc} */ - public function processOutbound($route_name, Route $route, array &$parameters, ?BubbleableMetadata $bubbleable_metadata = NULL) { + public function processOutbound($route_name, Route $route, array &$parameters, ?BubbleableMetadata $bubbleable_metadata = NULL): void { + // Add our 'in_trash' parameter to routes that need it. + if ($route->hasOption('_trash_route')) { + $parameters['in_trash'] = TRUE; + } + // Check if we're viewing a deleted entity and ensure that any other links // displayed on the page (e.g. local tasks) have the proper trash context. $request = $this->requestStack->getCurrentRequest(); - if ($request->query->has('in_trash')) { - $parts = explode('.', $this->routeMatch->getRouteName()); + $current_route = $this->routeMatch->getRouteObject(); + if (!$request || $current_route) { + return; + } + + // Ensure that the code can also be executed when there is no active route + // match, like on exception responses. + if (($route_name = $this->routeMatch->getRouteName()) && $request->query->has('in_trash')) { + $parts = explode('.', $route_name); if ($parts[0] === 'entity') { $entity_type_id = $parts[1]; $entity_id = $this->routeMatch->getRawParameter($entity_type_id); diff --git a/src/Routing/RouteEnhancer.php b/src/Routing/RouteEnhancer.php deleted file mode 100644 index 01b62e1b353940d6a11c034991ac77f4979c34f4..0000000000000000000000000000000000000000 --- a/src/Routing/RouteEnhancer.php +++ /dev/null @@ -1,53 +0,0 @@ -<?php - -declare(strict_types=1); - -namespace Drupal\trash\Routing; - -use Drupal\Core\Routing\EnhancerInterface; -use Drupal\Core\Routing\RouteObjectInterface; -use Drupal\Core\Session\AccountInterface; -use Drupal\trash\TrashManagerInterface; -use Symfony\Component\HttpFoundation\Request; -use Symfony\Component\Routing\Route; - -/** - * Sets the trash context for entity routes. - */ -class RouteEnhancer implements EnhancerInterface { - - public function __construct( - protected AccountInterface $currentUser, - protected TrashManagerInterface $trashManager, - ) {} - - /** - * {@inheritdoc} - */ - public function enhance(array $defaults, Request $request): array { - if ($this->applies($defaults[RouteObjectInterface::ROUTE_OBJECT], $request)) { - $this->trashManager->setTrashContext('inactive'); - } - - return $defaults; - } - - /** - * Determines whether the enhancer should run on the current route. - * - * @param \Symfony\Component\Routing\Route $route - * The current route. - * @param \Symfony\Component\HttpFoundation\Request $request - * The Request instance. - * - * @return bool - * TRUE if the enhancer should run, FALSE otherwise. - */ - protected function applies(Route $route, Request $request): bool { - $is_trash_route = (bool) $route->getOption('_trash_route'); - $has_trash_query = $request->query->has('in_trash'); - - return ($is_trash_route || $has_trash_query) && $this->currentUser->hasPermission('view deleted entities'); - } - -} diff --git a/src/Routing/RouteSubscriber.php b/src/Routing/RouteSubscriber.php index b4a56174b59579254ea65952e8d1dee7e408ea55..fc7c9aeea8d99d6c6051fb48ba9d2f43b105af29 100644 --- a/src/Routing/RouteSubscriber.php +++ b/src/Routing/RouteSubscriber.php @@ -108,11 +108,6 @@ class RouteSubscriber extends RouteSubscriberBase { } } } - - // Allow trashed entities to be displayed on the workspace manage page. - if ($route = $collection->get('entity.workspace.canonical')) { - $route->setOption('_trash_route', TRUE); - } } /** diff --git a/src/TrashManager.php b/src/TrashManager.php index 21fa0ba20ffa83043451e8e6e32a047093c49e0d..9535cbeb412413c618df0c28ccafd4aca585926a 100644 --- a/src/TrashManager.php +++ b/src/TrashManager.php @@ -10,6 +10,8 @@ use Drupal\Core\Entity\EntityLastInstalledSchemaRepositoryInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\Sql\SqlEntityStorageInterface; use Drupal\Core\Field\BaseFieldDefinition; +use Drupal\trash\Handler\TrashHandlerInterface; +use Symfony\Component\DependencyInjection\Attribute\AutowireIterator; /** * Provides the Trash manager. @@ -27,6 +29,8 @@ class TrashManager implements TrashManagerInterface { protected EntityDefinitionUpdateManagerInterface $entityDefinitionUpdateManager, protected EntityLastInstalledSchemaRepositoryInterface $entityLastInstalledSchemaRepository, protected ConfigFactoryInterface $configFactory, + #[AutowireIterator(tag: 'trash_handler', indexAttribute: 'entity_type_id')] + protected iterable $trashHandlers = [], ) {} /** @@ -139,4 +143,16 @@ class TrashManager implements TrashManagerInterface { return $result; } + /** + * {@inheritdoc} + */ + public function getHandler(string $entity_type_id): ?TrashHandlerInterface { + $handlers = iterator_to_array($this->trashHandlers); + if (isset($handlers[$entity_type_id])) { + return $handlers[$entity_type_id]; + } + + return NULL; + } + } diff --git a/src/TrashManagerInterface.php b/src/TrashManagerInterface.php index d6fcbbe244c2322df70222a47c5aaeee1519b0e7..ab246ae6fc8b039e683286e5b8d42028ffb6f710 100644 --- a/src/TrashManagerInterface.php +++ b/src/TrashManagerInterface.php @@ -1,8 +1,11 @@ <?php +declare(strict_types=1); + namespace Drupal\trash; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\trash\Handler\TrashHandlerInterface; /** * Provides an interface for the Trash manager. @@ -100,4 +103,18 @@ interface TrashManagerInterface { */ public function executeInTrashContext($context, callable $function): mixed; + /** + * Gets the trash handler for a given entity type. + * + * @param string $entity_type_id + * The entity type ID. + * + * @return \Drupal\trash\Handler\TrashHandlerInterface|null + * The trash handler for the given entity type, or NULL if the entity type + * is not enabled. + * + * @see \Drupal\trash\Handler\TrashHandlerPass + */ + public function getHandler(string $entity_type_id): ?TrashHandlerInterface; + } diff --git a/src/TrashPermissions.php b/src/TrashPermissions.php index 222e6bf3de6b5917b75932594181e3e3c8f5cc2c..0cccc581068831afb37919634832b1721df74280 100644 --- a/src/TrashPermissions.php +++ b/src/TrashPermissions.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; @@ -14,9 +16,6 @@ class TrashPermissions implements ContainerInjectionInterface { use StringTranslationTrait; - /** - * Constructor. - */ public function __construct( protected EntityTypeManagerInterface $entityTypeManager, protected TrashManagerInterface $trashManager, @@ -25,7 +24,7 @@ class TrashPermissions implements ContainerInjectionInterface { /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('entity_type.manager'), $container->get('trash.manager') diff --git a/src/TrashServiceProvider.php b/src/TrashServiceProvider.php index 51cb917eedd5be7d02a786285a3fc36048383f7f..6ffbbc65acbc6a14feeae85df4310f9f9bb6df46 100644 --- a/src/TrashServiceProvider.php +++ b/src/TrashServiceProvider.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash; use Drupal\Core\DependencyInjection\ContainerBuilder; @@ -8,6 +10,8 @@ use Drupal\trash\EntityQuery\Sql\PgsqlQueryFactory as CorePgsqlQueryFactory; use Drupal\trash\EntityQuery\Sql\QueryFactory as CoreQueryFactory; use Drupal\trash\EntityQuery\Workspaces\PgsqlQueryFactory as WorkspacesPgsqlQueryFactory; use Drupal\trash\EntityQuery\Workspaces\QueryFactory as WorkspacesQueryFactory; +use Drupal\trash\Handler\TrashHandlerPass; +use Drupal\trash\LayoutBuilder\TrashInlineBlockUsage; use Symfony\Component\DependencyInjection\ChildDefinition; use Symfony\Component\DependencyInjection\Reference; @@ -16,6 +20,13 @@ use Symfony\Component\DependencyInjection\Reference; */ class TrashServiceProvider extends ServiceProviderBase { + /** + * {@inheritdoc} + */ + public function register(ContainerBuilder $container) { + $container->addCompilerPass(new TrashHandlerPass()); + } + /** * {@inheritdoc} */ @@ -72,6 +83,14 @@ class TrashServiceProvider extends ServiceProviderBase { ->addArgument(new Reference('trash.workspaces.manager.inner')) ->addArgument(new Reference('trash.manager')); } + + if ($container->hasDefinition('inline_block.usage')) { + $container->register('trash.inline_block.usage', TrashInlineBlockUsage::class) + ->setPublic(FALSE) + ->setDecoratedService('inline_block.usage') + ->addArgument(new Reference('trash.inline_block.usage.inner')) + ->addArgument(new Reference('trash.manager')); + } } } diff --git a/src/TrashStorageTrait.php b/src/TrashStorageTrait.php index 8682f39b31e432f9fbbb5c677743e91f0ad52caa..9774c1f5faf8fc945acadbfd3545d115808ad03a 100644 --- a/src/TrashStorageTrait.php +++ b/src/TrashStorageTrait.php @@ -9,6 +9,8 @@ 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; /** * Provides the ability to soft-delete entities at the storage level. @@ -40,15 +42,15 @@ trait TrashStorageTrait { parent::delete($to_delete); $field_name = 'deleted'; + $request_time = \Drupal::time()->getRequestTime(); $revisionable = $this->getEntityType()->isRevisionable(); try { $transaction = $this->database->startTransaction(); - $field_name = 'deleted'; - $request_time = \Drupal::time()->getRequestTime(); 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); foreach (array_keys($entity->getTranslationLanguages()) as $langcode) { @@ -69,6 +71,7 @@ trait TrashStorageTrait { $entity->setSyncing(TRUE)->save(); // Allow code to run after soft-deleting. + $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashDelete($entity); $this->invokeHook('trash_delete', $entity); } } @@ -101,6 +104,7 @@ trait TrashStorageTrait { $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()); @@ -117,6 +121,7 @@ trait TrashStorageTrait { $entity->setSyncing(TRUE)->save(); // Allow code to run after restoring from trash. + $this->getTrashManager()->getHandler($this->entityTypeId)->postTrashRestore($entity); $this->invokeHook('trash_restore', $entity); } }); @@ -170,15 +175,36 @@ trait TrashStorageTrait { return $query; } + if (!$revision_ids + && $this->getWorkspaceInformation()?->isEntityTypeSupported($this->entityType) + && ($active_workspace = $this->getWorkspaceManager()?->getActiveWorkspace()) + ) { + // Join the workspace_association table so we can select possible + // workspace-specific revisions. + $wa_join = $query->leftJoin('workspace_association', NULL, "[%alias].[target_entity_type_id] = '{$this->entityTypeId}' AND [%alias].[target_entity_id] = [base].[{$this->idKey}] AND [%alias].[workspace] = :active_workspace_id", [ + ':active_workspace_id' => $active_workspace->id(), + ]); + + // Joins must be in order. i.e, any tables you mention in the ON clause of + // a JOIN must appear prior to that JOIN. So we must ensure that the new + // 'workspace_association' table appears prior to the 'revision' one. + $tables =& $query->getTables(); + $revision = $tables['revision']; + unset($tables['revision']); + $tables['revision'] = $revision; + + $tables['revision']['condition'] = "[revision].[{$this->revisionKey}] = COALESCE([$wa_join].[target_entity_revision_id], [base].[{$this->revisionKey}])"; + } + $table_mapping = $this->getTableMapping(); $deleted_column = $table_mapping->getFieldColumnName($this->fieldStorageDefinitions['deleted'], 'value'); // Ensure that entity_load excludes deleted entities. - if ($revision_ids && ($revision_data = $this->getRevisionDataTable())) { + if ($revision_data = $this->getRevisionDataTable()) { $query->join($revision_data, 'revision_data', "[revision_data].[{$this->revisionKey}] = [revision].[{$this->revisionKey}]"); $query->condition("revision_data.$deleted_column", NULL, 'IS NULL'); } - elseif ($revision_ids) { + elseif ($this->getRevisionTable()) { $query->condition("revision.$deleted_column", NULL, 'IS NULL'); } elseif ($data_table = $this->getDataTable()) { @@ -206,6 +232,9 @@ trait TrashStorageTrait { unset($entities[$id]); } } + if (empty($entities)) { + return; + } parent::setPersistentCache($entities); } @@ -239,4 +268,18 @@ trait TrashStorageTrait { return \Drupal::service('language_manager'); } + /** + * Gets the workspace manager service. + */ + private function getWorkspaceManager(): ?WorkspaceManagerInterface { + return \Drupal::hasService('workspaces.manager') ? \Drupal::service('workspaces.manager') : NULL; + } + + /** + * Gets the workspace information service. + */ + private function getWorkspaceInformation(): ?WorkspaceInformationInterface { + return \Drupal::hasService('workspaces.information') ? \Drupal::service('workspaces.information') : NULL; + } + } diff --git a/src/TrashWorkspaceInformation.php b/src/TrashWorkspaceInformation.php index 3b77ca68f00852b84f020a916af2164bab5a8d3d..d7b1e7f4ba868d6ad7972a5ecf9b2bba21d40973 100644 --- a/src/TrashWorkspaceInformation.php +++ b/src/TrashWorkspaceInformation.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash; use Drupal\Core\Entity\EntityInterface; @@ -7,16 +9,11 @@ use Drupal\Core\Entity\EntityTypeInterface; use Drupal\workspaces\WorkspaceInformationInterface; use Drupal\workspaces\WorkspaceInterface; -@class_alias('Drupal\wse\Core\WorkspaceInformationInterface', 'Drupal\workspaces\WorkspaceInformationInterface'); - /** - * Provides an override for core's workspace association service. + * Provides a decorator for core's workspace information service. */ class TrashWorkspaceInformation implements WorkspaceInformationInterface { - /** - * Constructor. - */ public function __construct( protected WorkspaceInformationInterface $inner, protected TrashManagerInterface $trashManager, @@ -61,7 +58,7 @@ class TrashWorkspaceInformation implements WorkspaceInformationInterface { * {@inheritdoc} */ public function isEntityDeletable(EntityInterface $entity, WorkspaceInterface $workspace): bool { - if ($this->trashManager->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle())) { + if ($this->trashManager->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle()) && !trash_entity_is_deleted($entity)) { return TRUE; } diff --git a/src/TrashWorkspaceManager.php b/src/TrashWorkspaceManager.php index eca5963afb9967ef2dd3333cbbe1eb81340c4e52..ad5917f47b18750460dcd3f0f888addd60b79c45 100644 --- a/src/TrashWorkspaceManager.php +++ b/src/TrashWorkspaceManager.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash; use Drupal\Core\Entity\EntityTypeInterface; @@ -7,13 +9,10 @@ use Drupal\workspaces\WorkspaceInterface; use Drupal\workspaces\WorkspaceManagerInterface; /** - * Provides an override for core's workspace manager service. + * Provides a decorator for core's workspace manager service. */ class TrashWorkspaceManager implements WorkspaceManagerInterface { - /** - * Constructs a TrashWorkspaceManager object. - */ public function __construct( protected WorkspaceManagerInterface $inner, protected TrashManagerInterface $trashManager, diff --git a/src/ViewsQueryAlter.php b/src/ViewsQueryAlter.php index 3dcb4f3ffc34afa34f231277c17786e0722fb64d..c8e41c676bf9fb11b491d22c01bd684743ec22f9 100644 --- a/src/ViewsQueryAlter.php +++ b/src/ViewsQueryAlter.php @@ -1,5 +1,7 @@ <?php +declare(strict_types=1); + namespace Drupal\trash; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; @@ -35,7 +37,7 @@ class ViewsQueryAlter implements ContainerInjectionInterface { /** * {@inheritdoc} */ - public static function create(ContainerInterface $container) { + public static function create(ContainerInterface $container): static { return new static( $container->get('entity_type.manager'), $container->get('entity_field.manager'), @@ -56,6 +58,11 @@ class ViewsQueryAlter implements ContainerInjectionInterface { return; } + // Bail out early if the query has already been altered. + if (in_array('trash_altered', $query->tags, TRUE)) { + return; + } + // Find out what entity types are represented in this query. $entity_type_definitions = $this->entityTypeManager->getDefinitions(); foreach ($query->relationships as $relationship_table => $info) { @@ -115,34 +122,19 @@ class ViewsQueryAlter implements ContainerInjectionInterface { assert($table_mapping instanceof DefaultTableMapping); $field_storage_definitions = $this->entityFieldManager->getFieldStorageDefinitions($entity_type_id); - $has_delete_condition = FALSE; - // Try to find out whether any filter (normal or conditional filter) filters // by the delete column. In case it does opt out of adding a specific // delete column. $deleted_table_names = $table_mapping->getAllFieldTableNames('deleted'); $deleted_table_column = $table_mapping->getFieldColumnName($field_storage_definitions['deleted'], 'value'); - foreach ($query->where as $group) { - foreach ($group['conditions'] as $condition) { - // Look through all the tables involved in the query, and check for - // those that might contain the 'deleted' column, either the data or - // revision data table. - foreach ($query->getTableQueue() as $alias => $table_info) { - if (in_array($table_info['table'], $deleted_table_names, TRUE)) { - // Note: We use strpos because views for some reason has a field - // looking like "trash_test.Deleted > 0". - if (!empty($condition['field']) && strpos($condition['field'], "{$alias}.{$deleted_table_column}") !== FALSE) { - $has_delete_condition = TRUE; - } - } - } - } - } + + $has_delete_condition = $this->hasDeleteCondition($query, $deleted_table_names, $deleted_table_column); // If we couldn't find any condition that filters out explicitly on deleted, // ensure that we just return not deleted entities. if (!$has_delete_condition) { $query->addWhere(0, "{$deleted_table_name}.{$deleted_table_column}", NULL, 'IS NULL'); + $query->addTag('trash_altered'); } // Otherwise ignore trash for the duration of this view, so it can load and // display deleted entities. @@ -152,6 +144,53 @@ class ViewsQueryAlter implements ContainerInjectionInterface { } } + /** + * Check if any filter of the query contains a delete condition. + * + * @param \Drupal\views\Plugin\views\query\Sql $query + * The query plugin object for the query. + * @param array $deleted_table_names + * List of table names with delete column. + * @param string $deleted_table_column + * Name of delete column. + * + * @return bool + * <code>TRUE</code> if the query has a delete condition, <code>FALSE</code> + * otherwise. + */ + protected function hasDeleteCondition(Sql $query, array $deleted_table_names, string $deleted_table_column): bool { + if (count($deleted_table_names) === 0) { + return FALSE; + } + // Get aliases of all tables involved in the query having the "deleted" + // field. + $aliases = array_keys(array_filter($query->getTableQueue(), function ($table_info) use ($deleted_table_names) { + return in_array($table_info['table'], $deleted_table_names, TRUE); + })); + if (count($aliases) === 0) { + return FALSE; + } + foreach ($query->where as $group) { + foreach ($group['conditions'] as $condition) { + if (!isset($condition['field']) || !is_string($condition['field'])) { + continue; + } + // Look through all the tables involved in the query, and check for + // those that might contain the 'deleted' column, either the data or + // revision data table. + foreach ($aliases as $alias) { + // Note: We use strpos because views for some reason has a field + // looking like "trash_test.Deleted > 0". + if (strpos($condition['field'], "{$alias}.{$deleted_table_column}") !== FALSE) { + return TRUE; + } + } + } + } + + return FALSE; + } + /** * Implements a hook bridge for hook_views_post_render(). * diff --git a/templates/TrashStorageSchema.php.twig b/templates/TrashStorageSchema.php.twig index 404d40b36a7cfacb26e86cc1a85c6f33ccdf196a..bdb0d4eed8ee3e7ec94a11ba59e3ea41ee7e63ec 100644 --- a/templates/TrashStorageSchema.php.twig +++ b/templates/TrashStorageSchema.php.twig @@ -10,7 +10,7 @@ class {{ trash_class }} extends \{{ original_class }} { /** * {@inheritdoc} */ - protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $storage_definition, $table_name, array $column_mapping) { + protected function getSharedTableFieldSchema(FieldStorageDefinitionInterface $storage_definition, $table_name, array $column_mapping): array { $schema = parent::getSharedTableFieldSchema($storage_definition, $table_name, $column_mapping); // @todo Add the 'deleted' field to the required indexes. diff --git a/tests/modules/trash_test/src/Entity/TrashTestEntity.php b/tests/modules/trash_test/src/Entity/TrashTestEntity.php index b0aa97a7e1202baf7845553cb1bf4731da0f937c..9e9a11d5b564549b84ffab141fd9e128f452e252 100644 --- a/tests/modules/trash_test/src/Entity/TrashTestEntity.php +++ b/tests/modules/trash_test/src/Entity/TrashTestEntity.php @@ -13,11 +13,11 @@ use Drupal\Core\Field\BaseFieldDefinition; * id = "trash_test_entity", * label = @Translation("Trash test"), * label_collection = @Translation("Trash test"), - * label_singular = @Translation("Trash test"), - * label_plural = @Translation("Trash tests"), + * label_singular = @Translation("Trash test entity"), + * label_plural = @Translation("Trash test entities"), * label_count = @PluralTranslation( - * singular = "@count trash test", - * plural = "@count trash tests" + * singular = "@count trash test entity", + * plural = "@count trash test entities", * ), * handlers = { * "route_provider" = { diff --git a/tests/src/Functional/TrashNodeTest.php b/tests/src/Functional/TrashNodeTest.php index 098e12be6ba62265db6ecff10ed116591ae5622a..f8b8d62fb29ccf02df0ec5ca6001edd49620a36e 100644 --- a/tests/src/Functional/TrashNodeTest.php +++ b/tests/src/Functional/TrashNodeTest.php @@ -28,7 +28,7 @@ class TrashNodeTest extends BrowserTestBase { /** * {@inheritdoc} */ - protected static $modules = ['block', 'node', 'trash']; + protected static $modules = ['block', 'node', 'trash', 'trash_test']; /** * {@inheritdoc} @@ -41,6 +41,12 @@ class TrashNodeTest extends BrowserTestBase { protected function setUp(): void { parent::setUp(); + // Trash support for nodes is added in trash_modules_installed(), which runs + // after the module installer rebuilds the router, so we have to do it + // manually here. + // @todo Revisit after https://www.drupal.org/i/3496588 + \Drupal::service('router.builder')->rebuild(); + // Create Basic page node type. if ($this->profile != 'standard') { $this->drupalCreateContentType([ @@ -69,6 +75,7 @@ class TrashNodeTest extends BrowserTestBase { 'view deleted entities', 'purge node entities', 'restore node entities', + 'administer trash', ]); $this->drupalPlaceBlock('local_tasks_block', ['id' => 'page_tabs_block']); $this->drupalPlaceBlock('local_actions_block', ['id' => 'page_actions_block']); @@ -128,6 +135,33 @@ class TrashNodeTest extends BrowserTestBase { $this->assertSession()->statusMessageContains('The content item ' . $node->getTitle() . ' has been restored from trash.', 'status'); } + /** + * Tests that the trash confirmation text varies as expected. + */ + public function testTrashConfirmationTextVariesAppropriately(): void { + // Login as a privileged user. + $this->drupalLogin($this->adminUser); + + $node = $this->drupalCreateNode([ + 'title' => $this->randomMachineName(8), + ]); + + $this->drupalGet('node/' . $node->id() . '/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.'); + + // Enable auto purge. + $this->drupalGet('admin/config/content/trash'); + $edit = [ + 'auto_purge[enabled]' => TRUE, + 'auto_purge[after]' => '30 days', + ]; + $this->submitForm($edit, 'Save configuration'); + $this->assertSession()->statusCodeEquals(200); + + $this->drupalGet('node/' . $node->id() . '/delete'); + $this->assertSession()->pageTextContains('Deleting this content item will move it to the trash. You can restore it from the trash for a limited period of time ('); + } + /** * Test moving a node to the trash bin and purging it. */ @@ -156,6 +190,48 @@ class TrashNodeTest extends BrowserTestBase { $this->assertSession()->pageTextContains('This action cannot be undone.'); $this->submitForm([], 'Confirm'); $this->assertSession()->statusMessageContains('The content item ' . $node->getTitle() . ' has been permanently deleted.', 'status'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('There are no deleted content items.'); + } + + /** + * Test uninstalling a trash-enabled entity type. + */ + public function testUninstallNode(): void { + // Login as a privileged user. + $this->drupalLogin($this->drupalCreateUser([ + 'administer modules', + 'administer trash', + ])); + + // Enable trash for one other entity type. + $this->drupalGet('admin/config/content/trash'); + $this->assertSession()->statusCodeEquals(200); + + $this->assertSession()->checkboxChecked('enabled_entity_types[node][enabled]'); + $this->assertSession()->checkboxNotChecked('enabled_entity_types[trash_test_entity][enabled]'); + + $edit = [ + 'enabled_entity_types[node][enabled]' => TRUE, + 'enabled_entity_types[trash_test_entity][enabled]' => TRUE, + ]; + $this->submitForm($edit, 'Save configuration'); + $this->assertSession()->statusCodeEquals(200); + + $this->drupalGet('/admin/content/trash/trash_test_entity'); + $this->assertSession()->statusCodeEquals(200); + + // Uninstall the node module. + $edit = [ + 'uninstall[node]' => TRUE, + ]; + $this->drupalGet('admin/modules/uninstall'); + $this->submitForm($edit, 'Uninstall'); + $this->submitForm([], 'Uninstall'); + + $this->drupalGet('/admin/content/trash'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->pageTextContains('There are no deleted Trash test entities.'); } } diff --git a/tests/src/Kernel/EntityAccessTest.php b/tests/src/Kernel/EntityAccessTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1a9142576bac2a8339760eb9cc0eac648b6c8391 --- /dev/null +++ b/tests/src/Kernel/EntityAccessTest.php @@ -0,0 +1,114 @@ +<?php + +declare(strict_types=1); + +namespace Drupal\Tests\trash\Kernel; + +/** + * Tests entity access for trashed entities. + * + * @group trash + */ +class EntityAccessTest extends TrashKernelTestBase { + + /** + * Tests entity access for trashed entities. + * + * @dataProvider providerTestEntityAccess + */ + public function testEntityAccess(string $permission, array $access_map): void { + $account = $this->createUser([ + 'access content', + $permission, + ]); + + $node = $this->createNode(['type' => 'article', 'uid' => $account->id()]); + $node->delete(); + + foreach ($access_map as $operation => $access_result) { + $this->assertSame($access_result, $node->access($operation, $account)); + } + } + + /** + * Data provider for self::testConstructor() + */ + public static function providerTestEntityAccess() { + return [ + [ + 'bypass node access', + [ + 'view' => FALSE, + 'update' => FALSE, + 'delete' => FALSE, + 'restore' => FALSE, + 'purge' => FALSE, + ], + ], + [ + 'edit any article content', + [ + 'view' => FALSE, + 'update' => FALSE, + 'delete' => FALSE, + 'restore' => FALSE, + 'purge' => FALSE, + ], + ], + [ + 'delete any article content', + [ + 'view' => FALSE, + 'update' => FALSE, + 'delete' => FALSE, + 'restore' => FALSE, + 'purge' => FALSE, + ], + ], + [ + 'view deleted entities', + [ + 'view' => TRUE, + 'update' => FALSE, + 'delete' => FALSE, + 'restore' => FALSE, + 'purge' => FALSE, + ], + ], + [ + 'restore node entities', + [ + 'view' => FALSE, + 'update' => FALSE, + 'delete' => FALSE, + 'restore' => TRUE, + 'purge' => FALSE, + ], + ], + [ + 'purge node entities', + [ + 'view' => FALSE, + 'update' => FALSE, + 'delete' => FALSE, + 'restore' => FALSE, + 'purge' => TRUE, + ], + ], + ]; + } + + /** + * Tests entity access for entity types that are not enabled. + */ + public function testEntityAccessForNotDeletedEntity(): void { + $account = $this->createUser([ + 'administer users', + ]); + + $this->assertTrue($account->access('view', $account)); + $this->assertFalse($account->access('restore', $account)); + $this->assertFalse($account->access('purge', $account)); + } + +} diff --git a/tests/src/Kernel/TrashKernelTest.php b/tests/src/Kernel/TrashKernelTest.php index 554521ae06992058b24bdbde12da0eb84c4186d8..bcb82072c1ac95ee8dd8709d0ef3a41d178d3eae 100644 --- a/tests/src/Kernel/TrashKernelTest.php +++ b/tests/src/Kernel/TrashKernelTest.php @@ -4,7 +4,9 @@ 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; /** * Tests basic trash functionality. @@ -23,6 +25,7 @@ class TrashKernelTest extends TrashKernelTestBase { $entity_id = $entity->id(); $this->assertNotNull(TrashTestEntity::load($entity_id)); + $this->assertFalse(trash_entity_is_deleted($entity)); $this->assertTrue($entity->get('deleted')->isEmpty()); $this->assertNull($entity->get('deleted')->value); @@ -38,6 +41,7 @@ class TrashKernelTest extends TrashKernelTestBase { }); assert($entity instanceof ContentEntityInterface); $this->assertNotNull($entity, 'Deleted entities can still be loaded in the "ignore" trash context.'); + $this->assertTrue(trash_entity_is_deleted($entity)); $this->assertEquals(\Drupal::time()->getRequestTime(), $entity->get('deleted')->value); $second_entity = TrashTestEntity::create(); @@ -58,6 +62,29 @@ class TrashKernelTest extends TrashKernelTestBase { $this->assertEquals($second_entity_id, $entities[$second_entity_id]->id()); } + /** + * @covers ::trash_entity_is_deleted + */ + public function testDisabledEntityType(): void { + // Check a node bundle that's not trash-enabled. + $nonDeletableNode = $this->createNode(['type' => 'page']); + $nonDeletableNode->save(); + $this->assertFalse(trash_entity_is_deleted($nonDeletableNode)); + + $nonDeletableNode->delete(); + $nonDeletableNode = Node::load($nonDeletableNode->id()); + $this->assertNull($nonDeletableNode); + + // Check an entity type that's not trash-enabled. + $nonDeletableEntity = $this->createUser(); + $nonDeletableEntity->save(); + $this->assertFalse(trash_entity_is_deleted($nonDeletableEntity)); + + $nonDeletableEntity->delete(); + $nonDeletableEntity = User::load($nonDeletableEntity->id()); + $this->assertNull($nonDeletableEntity); + } + /** * @covers \Drupal\trash\TrashManager::isEntityTypeEnabled * diff --git a/tests/src/Kernel/TrashKernelTestBase.php b/tests/src/Kernel/TrashKernelTestBase.php index 33564e8852bb074fb5ebcb0a8f34781083e63718..42240221af0c30746f25725273a59767304b4e21 100644 --- a/tests/src/Kernel/TrashKernelTestBase.php +++ b/tests/src/Kernel/TrashKernelTestBase.php @@ -3,6 +3,9 @@ namespace Drupal\Tests\trash\Kernel; use Drupal\KernelTests\KernelTestBase; +use Drupal\Tests\node\Traits\ContentTypeCreationTrait; +use Drupal\Tests\node\Traits\NodeCreationTrait; +use Drupal\Tests\user\Traits\UserCreationTrait; use Drupal\trash\TrashManagerInterface; /** @@ -10,32 +13,49 @@ use Drupal\trash\TrashManagerInterface; */ abstract class TrashKernelTestBase extends KernelTestBase { + use ContentTypeCreationTrait; + use NodeCreationTrait; + use UserCreationTrait; + /** * {@inheritdoc} */ protected static $modules = [ + 'field', 'file', + 'filter', 'image', 'node', 'media', + 'text', 'trash', 'trash_test', 'user', 'system', ]; + /** + * {@inheritdoc} + */ + protected bool $usesSuperUserAccessPolicy = FALSE; + /** * {@inheritdoc} */ protected function setUp(): void { parent::setUp(); - $this->installConfig(['trash_test']); $this->installEntitySchema('file'); $this->installEntitySchema('node'); $this->installEntitySchema('media'); $this->installEntitySchema('user'); $this->installEntitySchema('trash_test_entity'); + $this->installSchema('node', ['node_access']); + $this->installSchema('user', ['users_data']); + $this->installConfig(['node', 'filter', 'trash_test']); + + $this->createContentType(['type' => 'article']); + $this->createContentType(['type' => 'page']); $config = \Drupal::configFactory()->getEditable('trash.settings'); $enabled_entity_types = $config->get('enabled_entity_types'); @@ -43,6 +63,10 @@ abstract class TrashKernelTestBase extends KernelTestBase { $enabled_entity_types['node'] = ['article']; $config->set('enabled_entity_types', $enabled_entity_types); $config->save(); + + // Rebuild the container so trash handlers are available for the enabled + // entity types. + $this->container->get('kernel')->rebuildContainer(); } /** diff --git a/tests/src/Kernel/TrashWorkspacesTest.php b/tests/src/Kernel/TrashWorkspacesTest.php new file mode 100644 index 0000000000000000000000000000000000000000..1dcc4fcf08706e2566c634ef7bb44f30f5980acf --- /dev/null +++ b/tests/src/Kernel/TrashWorkspacesTest.php @@ -0,0 +1,126 @@ +<?php + +namespace Drupal\Tests\trash\Kernel; + +use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Tests\workspaces\Kernel\WorkspaceTestTrait; +use Drupal\workspaces\Entity\Workspace; + +/** + * Tests Trash integration with Workspaces. + * + * @group trash + */ +class TrashWorkspacesTest extends TrashKernelTestBase { + + use WorkspaceTestTrait; + + /** + * {@inheritdoc} + */ + protected static $modules = [ + 'workspaces', + ]; + + /** + * The entity type manager. + */ + protected EntityTypeManagerInterface $entityTypeManager; + + /** + * {@inheritdoc} + */ + protected function setUp(): void { + parent::setUp(); + + $this->entityTypeManager = \Drupal::entityTypeManager(); + $this->workspaceManager = \Drupal::service('workspaces.manager'); + + $this->installSchema('workspaces', ['workspace_association']); + $this->installEntitySchema('workspace'); + + $this->workspaces['stage'] = Workspace::create(['id' => 'stage', 'label' => 'Stage']); + $this->workspaces['stage']->save(); + + $this->setCurrentUser($this->createUser([ + 'view any workspace', + ])); + } + + /** + * Test trashing entities in a workspace. + */ + public function testDeletion(): void { + $live_node = $this->createNode(['type' => 'article']); + $live_node->save(); + + // Activate a workspace and delete the node. + $this->switchToWorkspace('stage'); + + $ws_node = $this->createNode(['type' => 'article']); + $ws_node->save(); + + $live_node->delete(); + $ws_node->delete(); + + $this->assertTrue(trash_entity_is_deleted($live_node)); + $this->assertTrue(trash_entity_is_deleted($ws_node)); + + // Check loading the deleted nodes in a workspace. + $storage = $this->entityTypeManager->getStorage('node'); + + $this->assertNull($storage->load($live_node->id())); + $this->assertNull($storage->loadRevision($live_node->getRevisionId())); + + $this->assertNull($storage->load($ws_node->id())); + $this->assertNull($storage->loadRevision($ws_node->getRevisionId())); + + // Switch back to Live and check that the nodes are not marked as deleted. + $this->switchToLive(); + + $live_node = $storage->load($live_node->id()); + $this->assertNotNull($live_node); + $this->assertTrue($live_node->isPublished()); + $this->assertNotNull($storage->loadRevision($live_node->getRevisionId())); + $this->assertFalse(trash_entity_is_deleted($live_node)); + + $ws_node = $storage->load($ws_node->id()); + $this->assertNotNull($ws_node); + $this->assertFalse($ws_node->isPublished()); + $this->assertNotNull($storage->loadRevision($ws_node->getRevisionId())); + $this->assertFalse(trash_entity_is_deleted($ws_node)); + + // Publish the workspace and check that both nodes are now deleted in Live. + $this->workspaces['stage']->publish(); + + $this->assertNull($storage->load($live_node->id())); + $this->assertNull($storage->load($ws_node->id())); + } + + /** + * Test 'purge' entity access in a workspace. + */ + public function testPurgeAccess(): void { + $this->setCurrentUser($this->createUser([ + 'access content', + 'view any workspace', + 'purge node entities', + ])); + + $live_node = $this->createNode(['type' => 'article']); + $live_node->save(); + + // Activate a workspace and delete the node. + $this->switchToWorkspace('stage'); + + $ws_node = $this->createNode(['type' => 'article']); + $ws_node->save(); + + $live_node->delete(); + $ws_node->delete(); + + $this->assertFalse($live_node->access('purge')); + $this->assertTrue($ws_node->access('purge')); + } + +} diff --git a/trash.install b/trash.install index 94d0e6a021e1484e2f80a97ce64f9c1f2097a9b7..592149de3c41c7eb305c9fef9a71525aa87baa9d 100644 --- a/trash.install +++ b/trash.install @@ -25,9 +25,17 @@ function trash_update_9301(): void { } /** - * Add translation support to the Trash module. + * Add the compact_overview configuration. */ 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) { diff --git a/trash.libraries.yml b/trash.libraries.yml index 8b04a3c9579095044c11e71890215ce7f55e8df8..57e19198ef5a96f2b900498d615ac322e1173ad9 100644 --- a/trash.libraries.yml +++ b/trash.libraries.yml @@ -1,5 +1,14 @@ trash: - version: VERSION css: base: css/trash.css: {} + +trash.admin: + js: + js/trash.js: {} + css: + base: + css/trash.admin.css: {} + dependencies: + - core/drupal + - core/once diff --git a/trash.module b/trash.module index 2912b213bb33603a6bba5c5ef23e0366cb660c9d..5267d9d260b5743a770fa712b9916dbf76614953 100644 --- a/trash.module +++ b/trash.module @@ -5,28 +5,31 @@ * Module implementation file. */ +use Drupal\Component\Serialization\Json; use Drupal\Core\Access\AccessResult; +use Drupal\Core\Cache\CacheableMetadata; use Drupal\Core\Database\Query\AlterableInterface; use Drupal\Core\Entity\ContentEntityDeleteForm; use Drupal\Core\Entity\Display\EntityViewDisplayInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeInterface; +use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\Form\DeleteMultipleForm; use Drupal\Core\Entity\Query\QueryInterface; use Drupal\Core\Entity\TranslatableInterface; use Drupal\Core\Field\BaseFieldDefinition; use Drupal\Core\Form\FormStateInterface; -use Drupal\Core\Language\LanguageInterface; +use Drupal\Core\Hook\Attribute\LegacyHook; use Drupal\Core\PhpStorage\PhpStorageFactory; use Drupal\Core\Render\Element; use Drupal\Core\Session\AccountInterface; use Drupal\Core\StringTranslation\TranslatableMarkup; use Drupal\Core\Url; use Drupal\menu_link_content\MenuLinkContentInterface; -use Drupal\trash\Controller\TrashController; use Drupal\trash\EntityHandler\TrashNodeAccessControlHandler; use Drupal\trash\Form\EntityPurgeForm; use Drupal\trash\Form\EntityRestoreForm; +use Drupal\trash\Handler\TrashHandlerInterface; use Drupal\trash\ViewsQueryAlter; use Drupal\views\Plugin\views\query\QueryPluginBase; use Drupal\views\ViewExecutable; @@ -41,8 +44,11 @@ use Drupal\views\ViewExecutable; * TRUE if the entity is deleted, FALSE otherwise. */ function trash_entity_is_deleted(EntityInterface $entity): bool { - /** @var \Drupal\Core\Entity\FieldableEntityInterface $entity */ - return \Drupal::service('trash.manager')->isEntityTypeEnabled($entity->getEntityType(), $entity->bundle()) + if (!$entity instanceof FieldableEntityInterface) { + return FALSE; + } + + return $entity->getFieldDefinition('deleted')?->getFieldStorageDefinition()->getProvider() === 'trash' && !$entity->get('deleted')->isEmpty(); } @@ -94,14 +100,41 @@ function trash_entity_access(EntityInterface $entity, $operation, AccountInterfa return AccessResult::neutral(); } - $entity_is_deleted = trash_entity_is_deleted($entity); + $cacheability = new CacheableMetadata(); + $cacheability->addCacheContexts(['user.permissions']); + $cacheability->addCacheableDependency($entity); + + if (trash_entity_is_deleted($entity)) { + // Check if users can view, restore or purge deleted entities. + if ($operation === 'view' && $account->hasPermission('view deleted entities')) { + return AccessResult::allowed()->addCacheableDependency($cacheability); + } + elseif ($operation === 'restore' && $account->hasPermission('restore ' . $entity->getEntityTypeId() . ' entities')) { + return AccessResult::allowed()->addCacheableDependency($cacheability); + } + elseif ($operation === 'purge' && $account->hasPermission('purge ' . $entity->getEntityTypeId() . ' entities')) { + // Ensure that trashed entities can only be purged in the workspace they + // were created in or in Live. + if (\Drupal::hasService('workspaces.manager') + && \Drupal::service('workspaces.information')->isEntitySupported($entity) + && ($active_workspace = \Drupal::service('workspaces.manager')->getActiveWorkspace()) + && !\Drupal::service('workspaces.information')->isEntityDeletable($entity, $active_workspace) + ) { + $cacheability->addCacheableDependency($active_workspace); + return AccessResult::forbidden()->addCacheableDependency($cacheability); + } - // Check if users can view deleted entities. - if ($operation === 'view' && $entity_is_deleted && $account->hasPermission('view deleted entities')) { - return AccessResult::allowed()->cachePerPermissions()->addCacheableDependency($entity); + return AccessResult::allowed()->addCacheableDependency($cacheability); + } + else { + return AccessResult::forbidden()->addCacheableDependency($cacheability); + } } - return AccessResult::forbiddenIf($entity_is_deleted)->addCacheableDependency($entity); + // If the entity is not deleted, the 'restore' and 'purge' operations should + // not be allowed. + return AccessResult::forbiddenIf($operation === 'restore' || $operation === 'purge') + ->addCacheableDependency($cacheability); } /** @@ -142,15 +175,10 @@ function trash_entity_query_alter(QueryInterface $query): void { /** * Implements hook_query_TAG_alter() for the 'search_node_search' tag. */ +#[LegacyHook] function trash_query_search_node_search_alter(AlterableInterface $query) { - /** @var \Drupal\Core\Database\Query\SelectInterface $query */ - $entity_type = \Drupal::entityTypeManager()->getDefinition('node'); - if (\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type)) { - // The core Search module is not using an entity query, so we need to alter - // its query manually. - // @see \Drupal\node\Plugin\Search\NodeSearch::findResults() - $query->isNull('n.deleted'); - } + // @phpstan-ignore-next-line + \Drupal::service('trash.manager')->getHandler('node')?->querySearchNodeSearchAlter($query); } /** @@ -220,6 +248,7 @@ function _trash_generate_storage_class($original_class, $type = 'storage') { */ 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)) { @@ -239,6 +268,7 @@ function trash_entity_type_alter(array &$entity_types) { } $entity_type->setLinkTemplate('restore', $base_path . '/restore'); $entity_type->setLinkTemplate('purge', $base_path . '/purge'); + if ($is_multilingual && $entity_type->isTranslatable()) { $entity_type->setLinkTemplate('restore-translation', $base_path . '/restore/{language}'); $entity_type->setLinkTemplate('purge-translation', $base_path . '/purge/{language}'); @@ -260,10 +290,13 @@ function trash_form_alter(&$form, FormStateInterface $form_state, $form_id) { $form_object = $form_state->getFormObject(); $is_entity_delete_form = $form_object instanceof ContentEntityDeleteForm; $is_entity_multiple_delete_form = $form_object instanceof DeleteMultipleForm; - if (!($is_entity_delete_form || $is_entity_multiple_delete_form)) { + $is_vbo_confirm_action_form = $form_id === 'views_bulk_operations_confirm_action'; + + if (!($is_entity_delete_form || $is_entity_multiple_delete_form || $is_vbo_confirm_action_form)) { return; } + $entity_type = $bundle = $entity = NULL; if ($is_entity_delete_form) { assert($form_object instanceof ContentEntityDeleteForm); $entity = $form_object->getEntity(); @@ -272,64 +305,152 @@ function trash_form_alter(&$form, FormStateInterface $form_state, $form_id) { } elseif ($is_entity_multiple_delete_form) { assert($form_object instanceof DeleteMultipleForm); - $entity = NULL; $entity_type_call = function () { // @phpstan-ignore-next-line return $this->entityType; }; $entity_type = $entity_type_call->call($form_object); - $bundle = NULL; } - if (!\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type, $bundle)) { + elseif ($is_vbo_confirm_action_form) { + $vbo_form_data = $form_state->getStorage()['views_bulk_operations']; + if ($vbo_form_data['action_id'] !== 'views_bulk_operations_delete_entity') { + return; + } + + // Get the first item in the VBO form data to check its entity type. + $first_item = reset($vbo_form_data['list']); + // @see \Drupal\views_bulk_operations\Form\ViewsBulkOperationsFormTrait::calculateEntityBulkFormKey() + $entity_type = \Drupal::entityTypeManager()->getDefinition($first_item[2]); + } + + if ($entity_type && !\Drupal::service('trash.manager')->isEntityTypeEnabled($entity_type, $bundle)) { return; } // Change the message of the delete confirmation form to mention the actual // action that is about to happen. - if (isset($form['description']['#markup']) && $form['description']['#markup'] instanceof TranslatableMarkup) { - $params = [ - '@label' => $is_entity_delete_form ? $entity_type->getSingularLabel() : $entity_type->getPluralLabel(), - ':link' => Url::fromRoute('trash.admin_content_trash_entity_type', [ - 'entity_type_id' => $entity_type->id(), - ])->toString(), - ]; + $params = [ + '@label' => !$is_entity_delete_form ? $entity_type->getPluralLabel() : $entity_type->getSingularLabel(), + ':link' => Url::fromRoute('trash.admin_content_trash_entity_type', [ + 'entity_type_id' => $entity_type->id(), + ])->toString(), + ]; - // Use different messages based on the user's access level. - if (\Drupal::currentUser()->hasPermission('restore ' . $entity_type->id() . ' entities')) { - $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>. You can restore it from the trash at a later date if necessary.', $params); - $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>. You can restore them from the trash at a later date if necessary.', $params); - } - elseif (\Drupal::currentUser()->hasPermission('access trash')) { - $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>.', $params); - $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>.', $params); + // Use different messages based on the user's access level. + if (\Drupal::currentUser()->hasPermission('restore ' . $entity_type->id() . ' entities')) { + $trash_settings = \Drupal::configFactory()->get('trash.settings'); + if ($trash_settings->get('auto_purge.enabled')) { + $timestamp = strtotime(sprintf('+%s', $trash_settings->get('auto_purge.after'))); + $params['@time_period'] = \Drupal::service('date.formatter')->formatDiff(\Drupal::time()->getCurrentTime(), $timestamp); + + $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>. You can restore it from the trash for a limited period of time (@time_period) if necessary.', $params); + $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>. You can restore them from the trash for a limited period of time (@time_period) if necessary.', $params); } else { - $entity_delete_label = t('Deleting this @label will move it to the trash.', $params); - $entity_multiple_delete_label = t('Deleting these @label will move them to the trash.', $params); + $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>. You can restore it from the trash at a later date if necessary.', $params); + $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>. You can restore them from the trash at a later date if necessary.', $params); } + } + elseif (\Drupal::currentUser()->hasPermission('access trash')) { + $entity_delete_label = t('Deleting this @label will move it to the <a href=":link">trash</a>.', $params); + $entity_multiple_delete_label = t('Deleting these @label will move them to the <a href=":link">trash</a>.', $params); + } + else { + $entity_delete_label = t('Deleting this @label will move it to the trash.', $params); + $entity_multiple_delete_label = t('Deleting these @label will move them to the trash.', $params); + } + $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) { if ($form['description']['#markup']->getUntranslatedString() === 'This action cannot be undone.') { if ($is_entity_delete_form) { $form['description']['#markup'] = $entity_delete_label; + $trash_handler->deleteFormAlter($form, $form_state); } elseif ($is_entity_multiple_delete_form) { $form['description']['#markup'] = $entity_multiple_delete_label; + $trash_handler->deleteFormAlter($form, $form_state, TRUE); } } } + elseif ($is_vbo_confirm_action_form) { + $form['description'] = [ + '#markup' => $entity_multiple_delete_label, + '#weight' => -10, + ]; + $trash_handler->deleteFormAlter($form, $form_state, TRUE); + } } /** * Implements hook_entity_operation_alter(). */ -function trash_entity_operation_alter(array &$operations, EntityInterface $entity) { - if (\Drupal::service('trash.manager')->isEntityTypeEnabled($entity->getEntityType()) && trash_entity_is_deleted($entity)) { - // Operations are intentionally overwritten. - $url_options = [ - 'language' => \Drupal::languageManager()->getCurrentLanguage(LanguageInterface::TYPE_INTERFACE), - 'query' => \Drupal::destination()->getAsArray(), +function trash_entity_operation_alter(array &$operations, EntityInterface $entity): void { + // Skip access checks for non-deleted entities. + if (!trash_entity_is_deleted($entity)) { + return; + } + + $url_options = [ + 'language' => \Drupal::languageManager()->getCurrentLanguage(), + ]; + + // Remove all other operations for deleted entities. + $operations = []; + if ($entity->access('restore')) { + $url_options['attributes']['aria-label'] = t('Restore @label', [ + '@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' => $restore_url, + 'weight' => 0, + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 880, + ]), + ], + ]; + } + if ($entity->access('purge')) { + $url_options['attributes']['aria-label'] = t('Purge @label', [ + '@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' => $purge_url, + 'weight' => 5, + 'attributes' => [ + 'class' => ['use-ajax'], + 'data-dialog-type' => 'modal', + 'data-dialog-options' => Json::encode([ + 'width' => 880, + ]), + ], ]; - $operations = TrashController::getOperations($entity, $url_options); } } @@ -358,17 +479,10 @@ function trash_entity_view(array &$build, EntityInterface $entity, EntityViewDis /** * Implements hook_ENTITY_TYPE_update() for 'menu_link_content'. */ -function trash_menu_link_content_update(MenuLinkContentInterface $entity) { - // Trash doesn't invoke hooks when an entity is deleted, so it needs to handle - // deleting menu link definitions. - // Additionally, it's essential that this is implemented in an update hook - // rather than a presave hook because it needs to run after - // \Drupal\menu_link_content\Entity\MenuLinkContent::postSave(). That method - // might add a deleted menu link to the Live menu tree when a workspace is - // published, so this code needs to run afterward in order to remove it again. - if (trash_entity_is_deleted($entity)) { - \Drupal::service('plugin.manager.menu.link')->removeDefinition($entity->getPluginId(), FALSE); - } +#[LegacyHook] +function trash_menu_link_content_update(MenuLinkContentInterface $entity): void { + // @phpstan-ignore-next-line + \Drupal::service('trash.manager')->getHandler('menu_link_content')?->entityUpdate($entity); } /** @@ -415,3 +529,18 @@ function trash_configurable_language_insert(EntityInterface $entity) { $router_builder->setRebuildNeeded(); } } + +/** + * Implements hook_modules_installed(). + */ +function trash_modules_installed($modules, $is_syncing) { + if (!$is_syncing && ( + in_array('node', $modules, TRUE) || + (in_array('trash', $modules, TRUE) && \Drupal::moduleHandler()->moduleExists('node')) + )) { + $trash_settings = \Drupal::configFactory()->getEditable('trash.settings'); + $enabled_entity_types = $trash_settings->get('enabled_entity_types'); + $enabled_entity_types['node'] = []; + $trash_settings->set('enabled_entity_types', $enabled_entity_types)->save(); + } +} diff --git a/trash.post_update.php b/trash.post_update.php index 8bb55cf1c4771e5aa975ad27ece8f07f1964924d..5570d5127a9bacfd54603874e69445b940b5d0a0 100644 --- a/trash.post_update.php +++ b/trash.post_update.php @@ -26,3 +26,10 @@ function trash_post_update_fix_missing_auto_purge(): void { $config->save(TRUE); } } + +/** + * Rebuild the container to register trash handlers. + */ +function trash_post_update_add_trash_handlers(): void { + // Empty update to trigger a container rebuild. +} diff --git a/trash.routing.yml b/trash.routing.yml index a882d0a8e5cdd9065fc803b1250bdfc82532e4ec..2ca45c44c212a70fedefadc6105ce18c6503870d 100644 --- a/trash.routing.yml +++ b/trash.routing.yml @@ -12,7 +12,7 @@ trash.admin_content_trash: _controller: '\Drupal\trash\Controller\TrashController::listing' _title: 'Trash' requirements: - _permission: 'access trash' + _permission: 'access trash+administer trash' trash.admin_content_trash_entity_type: path: '/admin/content/trash/{entity_type_id}' @@ -20,4 +20,4 @@ trash.admin_content_trash_entity_type: _controller: '\Drupal\trash\Controller\TrashController::listing' _title: 'Trash' requirements: - _permission: 'access trash' + _permission: 'access trash+administer trash' diff --git a/trash.services.yml b/trash.services.yml index fa163030778e8ca8454a2396095e409d1b092d0c..6594f4d46c5fe44a65d5032e6288bf9072924d78 100644 --- a/trash.services.yml +++ b/trash.services.yml @@ -1,7 +1,8 @@ services: trash.manager: class: Drupal\trash\TrashManager - arguments: ['@entity.definition_update_manager', '@entity.last_installed_schema.repository', '@config.factory'] + autowire: true + Drupal\trash\TrashManagerInterface: '@trash.manager' trash.entity_purger: class: Drupal\trash\TrashEntityPurger @@ -9,7 +10,13 @@ services: trash.config_subscriber: class: Drupal\trash\EventSubscriber\TrashConfigSubscriber - arguments: ['@entity_type.manager', '@trash.manager', '@entity.last_installed_schema.repository', '@router.builder'] + arguments: ['@entity_type.manager', '@trash.manager', '@entity.last_installed_schema.repository', '@router.builder', '@kernel'] + tags: + - { name: event_subscriber } + + trash.entity_schema_subscriber: + class: Drupal\trash\EventSubscriber\TrashEntitySchemaSubscriber + arguments: ['@trash.manager', '@config.factory'] tags: - { name: event_subscriber } @@ -21,7 +28,7 @@ services: trash.ignore_subscriber: class: Drupal\trash\EventSubscriber\TrashIgnoreSubscriber - arguments: ['@trash.manager'] + arguments: ['@trash.manager', '@current_route_match'] tags: - { name: event_subscriber } @@ -31,14 +38,6 @@ services: tags: - { name: event_subscriber } - trash.route_enhancer: - class: Drupal\trash\Routing\RouteEnhancer - arguments: ['@current_user', '@trash.manager'] - tags: - # Use a higher priority than route_enhancer.param_conversion, so trashed - # entities can be loaded by the entity converter. - - { name: route_enhancer, priority: 6000 } - trash.access_check: class: Drupal\trash\Access\TrashAccessCheck arguments: ['@entity_type.manager', '@language_manager', '@trash.manager'] @@ -54,3 +53,17 @@ services: trash.uninstall_validator: class: Drupal\trash\TrashUninstallValidator arguments: ['@entity_type.manager', '@trash.manager'] + + # Trash handlers. + trash.handler_configurator: + class: Drupal\trash\Handler\TrashHandlerConfigurator + public: false + autowire: true + + Drupal\trash\Hook\TrashHandler\NodeTrashHandler: + tags: + - { name: trash_handler, entity_type_id: node } + Drupal\trash\Hook\TrashHandler\MenuLinkContentTrashHandler: + autowire: true + tags: + - { name: trash_handler, entity_type_id: menu_link_content }