Commit 753ed892 authored by bojanz's avatar bojanz
Browse files

Issue #3023884 by bojanz: Add a UI for duplicating entities

parent 4919c813
......@@ -11,3 +11,4 @@ Current functionality:
- Query access API (Change record: https://www.drupal.org/node/3002038, core issue: #777578)
- Bundle plugin API (plugin-based entity bundles, currently not proposed for core inclusion)
- A generic UI for revisions (WIP, see #2625122)
- Duplicate entity UI
......@@ -6,6 +6,7 @@
*/
use Drupal\Core\Database\Query\SelectInterface;
use Drupal\Core\Entity\EntityInterface;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\entity\BundlePlugin\BundlePluginHandler;
use Drupal\entity\QueryAccess\EntityQueryAlter;
......@@ -14,6 +15,23 @@ use Drupal\views\Plugin\views\query\QueryPluginBase;
use Drupal\views\Plugin\views\query\Sql;
use Drupal\views\ViewExecutable;
/**
* Implements hook_entity_operation().
*/
function entity_entity_operation(EntityInterface $entity) {
$operations = [];
$entity_type = $entity->getEntityType();
if ($entity_type->hasLinkTemplate('duplicate-form') && $entity->access('duplicate')) {
$operations['duplicate'] = [
'title' => t('Duplicate'),
'weight' => 40,
'url' => $entity->toUrl('duplicate-form'),
];
}
return $operations;
}
/**
* Gets the entity types which use bundle plugins.
*
......
<?php
namespace Drupal\entity\Controller;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityRepositoryInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Form\FormState;
use Drupal\Core\Routing\RouteMatchInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Symfony\Component\DependencyInjection\ContainerInterface;
class EntityDuplicateController implements ContainerInjectionInterface {
use StringTranslationTrait;
/**
* The entity repository.
*
* @var \Drupal\Core\Entity\EntityRepositoryInterface
*/
protected $entityRepository;
/**
* The entity type manager.
*
* @var \Drupal\Core\Entity\EntityTypeManagerInterface
*/
protected $entityTypeManager;
/**
* The form builder.
*
* @var \Drupal\Core\Form\FormBuilderInterface
*/
protected $formBuilder;
/**
* Constructs a new EntityDuplicateController object.
*
* @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository
* The entity repository.
* @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager
* The entity type manager.
* @param \Drupal\Core\Form\FormBuilderInterface $form_builder
* The form builder.
* @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation
* The string translation.
*/
public function __construct(EntityRepositoryInterface $entity_repository, EntityTypeManagerInterface $entity_type_manager, FormBuilderInterface $form_builder, TranslationInterface $string_translation) {
$this->entityRepository = $entity_repository;
$this->entityTypeManager = $entity_type_manager;
$this->formBuilder = $form_builder;
$this->stringTranslation = $string_translation;
}
/**
* {@inheritdoc}
*/
public static function create(ContainerInterface $container) {
return new static(
$container->get('entity.repository'),
$container->get('entity_type.manager'),
$container->get('form_builder'),
$container->get('string_translation')
);
}
/**
* Builds the duplicate form.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*
* @return array
* The rendered form.
*/
public function form(RouteMatchInterface $route_match) {
$entity_type_id = $route_match->getRouteObject()->getDefault('entity_type_id');
$source_entity = $route_match->getParameter($entity_type_id);
$entity = $source_entity->createDuplicate();
/** @var \Drupal\entity\Form\EntityDuplicateFormInterface $form_object */
$form_object = $this->entityTypeManager->getFormObject($entity_type_id, 'duplicate');
$form_object->setEntity($entity);
$form_object->setSourceEntity($source_entity);
$form_state = new FormState();
return $this->formBuilder->buildForm($form_object, $form_state);
}
/**
* Provides the duplicate form title.
*
* @param \Drupal\Core\Routing\RouteMatchInterface $route_match
* The route match.
*
* @return string
* The duplicate form title.
*/
public function title(RouteMatchInterface $route_match) {
$entity_type_id = $route_match->getRouteObject()->getDefault('entity_type_id');
$source_entity = $route_match->getParameter($entity_type_id);
$source_entity = $this->entityRepository->getTranslationFromContext($source_entity);
return $this->t('Duplicate %label', ['%label' => $source_entity->label()]);
}
}
......@@ -40,8 +40,8 @@ class EntityAccessControlHandlerBase extends CoreEntityAccessControlHandler {
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity for which to check access.
* @param string $operation
* The entity operation. Usually one of 'view', 'view label', 'update' or
* 'delete'.
* The entity operation. Usually one of 'view', 'view label', 'update',
* 'duplicate' or 'delete'.
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to check access.
*
......@@ -63,8 +63,8 @@ class EntityAccessControlHandlerBase extends CoreEntityAccessControlHandler {
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity for which to check access.
* @param string $operation
* The entity operation. Usually one of 'view', 'view label', 'update' or
* 'delete'.
* The entity operation. Usually one of 'view', 'view label', 'update',
* 'duplicate' or 'delete'.
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to check access.
*
......
......@@ -16,6 +16,7 @@ use Drupal\Core\Entity\EntityTypeInterface;
* - view own unpublished $entity_type
* - view ($bundle) $entity_type
* - update (own|any) ($bundle) $entity_type
* - duplicate (own|any) ($bundle) $entity_type
* - delete (own|any) ($bundle) $entity_type
* - create $bundle $entity_type
*
......
......@@ -113,6 +113,7 @@ class EntityPermissionProviderBase implements EntityPermissionProviderInterface,
protected function buildEntityTypePermissions(EntityTypeInterface $entity_type) {
$entity_type_id = $entity_type->id();
$has_owner = $entity_type->entityClassImplements(EntityOwnerInterface::class);
$has_duplicate_form = $entity_type->hasLinkTemplate('duplicate-form');
$singular_label = $entity_type->getSingularLabel();
$plural_label = $entity_type->getPluralLabel();
......@@ -133,6 +134,18 @@ class EntityPermissionProviderBase implements EntityPermissionProviderInterface,
'@type' => $plural_label,
]),
];
if ($has_duplicate_form) {
$permissions["duplicate any {$entity_type_id}"] = [
'title' => $this->t('Duplicate any @type', [
'@type' => $singular_label,
]),
];
$permissions["duplicate own {$entity_type_id}"] = [
'title' => $this->t('Duplicate own @type', [
'@type' => $plural_label,
]),
];
}
$permissions["delete any {$entity_type_id}"] = [
'title' => $this->t('Delete any @type', [
'@type' => $singular_label,
......@@ -150,6 +163,13 @@ class EntityPermissionProviderBase implements EntityPermissionProviderInterface,
'@type' => $plural_label,
]),
];
if ($has_duplicate_form) {
$permissions["duplicate {$entity_type_id}"] = [
'title' => $this->t('Duplicate @type', [
'@type' => $plural_label,
]),
];
}
$permissions["delete {$entity_type_id}"] = [
'title' => $this->t('Delete @type', [
'@type' => $plural_label,
......@@ -173,6 +193,7 @@ class EntityPermissionProviderBase implements EntityPermissionProviderInterface,
$entity_type_id = $entity_type->id();
$bundles = $this->entityTypeBundleInfo->getBundleInfo($entity_type_id);
$has_owner = $entity_type->entityClassImplements(EntityOwnerInterface::class);
$has_duplicate_form = $entity_type->hasLinkTemplate('duplicate-form');
$singular_label = $entity_type->getSingularLabel();
$plural_label = $entity_type->getPluralLabel();
......@@ -198,6 +219,20 @@ class EntityPermissionProviderBase implements EntityPermissionProviderInterface,
'@type' => $plural_label,
]),
];
if ($has_duplicate_form) {
$permissions["duplicate any {$bundle_name} {$entity_type_id}"] = [
'title' => $this->t('@bundle: Duplicate any @type', [
'@bundle' => $bundle_info['label'],
'@type' => $singular_label,
]),
];
$permissions["duplicate own {$bundle_name} {$entity_type_id}"] = [
'title' => $this->t('@bundle: Duplicate own @type', [
'@bundle' => $bundle_info['label'],
'@type' => $plural_label,
]),
];
}
$permissions["delete any {$bundle_name} {$entity_type_id}"] = [
'title' => $this->t('@bundle: Delete any @type', [
'@bundle' => $bundle_info['label'],
......@@ -218,6 +253,14 @@ class EntityPermissionProviderBase implements EntityPermissionProviderInterface,
'@type' => $plural_label,
]),
];
if ($has_duplicate_form) {
$permissions["duplicate {$bundle_name} {$entity_type_id}"] = [
'title' => $this->t('@bundle: Duplicate @type', [
'@bundle' => $bundle_info['label'],
'@type' => $plural_label,
]),
];
}
$permissions["delete {$bundle_name} {$entity_type_id}"] = [
'title' => $this->t('@bundle: Delete @type', [
'@bundle' => $bundle_info['label'],
......
<?php
namespace Drupal\entity\Event;
use Drupal\Core\Entity\EntityInterface;
use Symfony\Component\EventDispatcher\Event;
/**
* Defines the entity duplicate event.
*
* @see \Drupal\entity\Event\EntityEvents
*/
class EntityDuplicateEvent extends Event {
/**
* The entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $entity;
/**
* The source entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $sourceEntity;
/**
* Constructs a new EntityDuplicateEvent object.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The entity.
* @param \Drupal\Core\Entity\EntityInterface $source_entity
* The source entity.
*/
public function __construct(EntityInterface $entity, EntityInterface $source_entity) {
$this->entity = $entity;
$this->sourceEntity = $source_entity;
}
/**
* Gets the entity.
*
* @return \Drupal\Core\Entity\EntityInterface
* The entity.
*/
public function getEntity() {
return $this->entity;
}
/**
* Gets the source entity.
*
* @return \Drupal\Core\Entity\EntityInterface
* The source entity.
*/
public function getSourceEntity() {
return $this->sourceEntity;
}
}
<?php
namespace Drupal\entity\Event;
/**
* Defines events for the Entity module.
*/
final class EntityEvents {
/**
* Name of the event fired after saving a duplicated entity.
*
* @Event
*
* @see \Drupal\entity\Event\EntityDuplicateEvent
*/
const ENTITY_DUPLICATE = 'entity.duplicate';
}
<?php
namespace Drupal\entity\Form;
use Drupal\Core\Entity\EntityFormInterface;
use Drupal\Core\Entity\EntityInterface;
/**
* Defines an interface for entity duplicate forms.
*/
interface EntityDuplicateFormInterface extends EntityFormInterface {
/**
* Gets the source entity.
*
* This is the entity that was duplicated to populate the form entity.
*
* @return \Drupal\Core\Entity\EntityInterface
* The source entity.
*/
public function getSourceEntity();
/**
* Sets the source entity.
*
* @param \Drupal\Core\Entity\EntityInterface $source_entity
* The source entity.
*
* @return $this
*/
public function setSourceEntity(EntityInterface $source_entity);
}
<?php
namespace Drupal\entity\Form;
use Drupal\Core\Entity\EntityInterface;
use Drupal\entity\Event\EntityDuplicateEvent;
use Drupal\entity\Event\EntityEvents;
/**
* Allows forms to implement EntityDuplicateFormInterface.
*
* Forms are expected to call $this->postSave() after the entity is saved.
* This works around core issue #3040556.
*/
trait EntityDuplicateFormTrait {
/**
* The source entity.
*
* @var \Drupal\Core\Entity\EntityInterface
*/
protected $sourceEntity;
/**
* {@inheritdoc}
*/
public function getSourceEntity() {
return $this->sourceEntity;
}
/**
* {@inheritdoc}
*/
public function setSourceEntity(EntityInterface $source_entity) {
$this->sourceEntity = $source_entity;
return $this;
}
/**
* Invokes entity duplicate hooks after the entity has been duplicated.
*
* @param \Drupal\Core\Entity\EntityInterface $entity
* The saved entity.
* @param string $operation
* The form operation.
*/
protected function postSave(EntityInterface $entity, $operation) {
if ($operation == 'duplicate') {
// An event is used instead of a hook to prevent a conflict with core
// once hook_entity_duplicate() is introduced there.
$event = new EntityDuplicateEvent($entity, $this->sourceEntity);
/** @var \Symfony\Component\EventDispatcher\EventDispatcherInterface $event_dispatcher */
$event_dispatcher = \Drupal::service('event_dispatcher');
$event_dispatcher->dispatch(EntityEvents::ENTITY_DUPLICATE, $event);
}
}
}
......@@ -41,7 +41,7 @@ class QueryAccessEvent extends Event {
* @param \Drupal\entity\QueryAccess\ConditionGroup $conditions
* The conditions.
* @param string $operation
* The operation. Usually one of "view", "update" or "delete".
* The operation. Usually one of "view", "update", "duplicate", or "delete".
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to restrict access.
*/
......
......@@ -97,7 +97,8 @@ abstract class QueryAccessHandlerBase implements EntityHandlerInterface, QueryAc
* Builds the conditions for the given operation and user.
*
* @param string $operation
* The access operation. Usually one of "view", "update" or "delete".
* The access operation. Usually one of "view", "update", "duplicate",
* or "delete".
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to restrict access.
*
......@@ -174,7 +175,8 @@ abstract class QueryAccessHandlerBase implements EntityHandlerInterface, QueryAc
* Builds the conditions for entities that have an owner.
*
* @param string $operation
* The access operation. Usually one of "view", "update" or "delete".
* The access operation. Usually one of "view", "update", "duplicate",
* or "delete".
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to restrict access.
*
......@@ -231,7 +233,8 @@ abstract class QueryAccessHandlerBase implements EntityHandlerInterface, QueryAc
* Builds the conditions for entities that do not have an owner.
*
* @param string $operation
* The access operation. Usually one of "view", "update" or "delete".
* The access operation. Usually one of "view", "update", "duplicate",
* or "delete".
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to restrict access.
*
......
......@@ -28,7 +28,8 @@ interface QueryAccessHandlerInterface {
* modules to alter the conditions.
*
* @param string $operation
* The access operation. Usually one of "view", "update" or "delete".
* The access operation. Usually one of "view", "update", "duplicate",
* or "delete".
* @param \Drupal\Core\Session\AccountInterface $account
* The user for which to restrict access, or NULL
* to assume the current user. Defaults to NULL.
......
......@@ -25,6 +25,7 @@ class AdminHtmlRouteProvider extends DefaultHtmlRouteProvider {
"entity.{$entity_type_id}.edit_form",
"entity.{$entity_type_id}.delete_form",
"entity.{$entity_type_id}.delete_multiple_form",
"entity.{$entity_type_id}.duplicate_form",
];
foreach ($admin_route_names as $admin_route_name) {
if ($route = $collection->get($admin_route_name)) {
......
......@@ -4,12 +4,28 @@ namespace Drupal\entity\Routing;
use Drupal\Core\Entity\EntityTypeInterface;
use Drupal\Core\Entity\Routing\DefaultHtmlRouteProvider as CoreDefaultHtmlRouteProvider;
use Drupal\entity\Controller\EntityDuplicateController;
use Symfony\Component\Routing\Route;
/**
* Provides HTML routes for entities.
*/
class DefaultHtmlRouteProvider extends CoreDefaultHtmlRouteProvider {
/**
* {@inheritdoc}
*/
public function getRoutes(EntityTypeInterface $entity_type) {
$collection = parent::getRoutes($entity_type);
$entity_type_id = $entity_type->id();
if ($duplicate_route = $this->getDuplicateFormRoute($entity_type)) {
$collection->add("entity.{$entity_type_id}.duplicate_form", $duplicate_route);
}
return $collection;
}
/**
* {@inheritdoc}
*/
......@@ -23,4 +39,37 @@ class DefaultHtmlRouteProvider extends CoreDefaultHtmlRouteProvider {
return $route;
}
/**
* Gets the duplicate-form route.
*
* @param \Drupal\Core\Entity\EntityTypeInterface $entity_type
* The entity type.
*
* @return \Symfony\Component\Routing\Route|null
* The generated route, if available.
*/
protected function getDuplicateFormRoute(EntityTypeInterface $entity_type) {
if ($entity_type->hasLinkTemplate('duplicate-form')) {
$entity_type_id = $entity_type->id();
$route = new Route($entity_type->getLinkTemplate('duplicate-form'));
$route
->setDefaults([
'_controller' => EntityDuplicateController::class . '::form',
'_title_callback' => EntityDuplicateController::class . '::title',
'entity_type_id' => $entity_type_id,
])
->setRequirement('_entity_access', "{$entity_type_id}.duplicate")
->setOption('parameters', [
$entity_type_id => ['type' => 'entity:' . $entity_type_id],
]);
// Entity types with serial IDs can specify this in their route
// requirements, improving the matching process.
if ($this->getEntityTypeIdKeyType($entity_type) === 'integer') {
$route->setRequirement($entity_type_id, '\d+');
}
return $route;
}
}
}
......@@ -17,6 +17,7 @@ use Drupal\user\EntityOwnerInterface;
* - view own unpublished $entity_type
* - view (own|any) ($bundle) $entity_type
* - update (own|any) ($bundle) $entity_type
* - duplicate (own|any) ($bundle) $entity_type
* - delete (own|any) ($bundle) $entity_type
* - create $bundle $entity_type
*
......
......@@ -27,8 +27,9 @@ use Drupal\entity\Revision\RevisionableContentEntityBase;
* "query_access" = "\Drupal\entity\QueryAccess\QueryAccessHandler",
* "permission_provider" = "\Drupal\entity\EntityPermissionProvider",
* "form" = {
* "add" = "\Drupal\entity\Form\RevisionableContentEntityForm",
* "edit" = "\Drupal\entity\Form\RevisionableContentEntityForm",
* "add" = "\Drupal\entity_module_test\Form\EnhancedEntityForm",
* "edit" = "\Drupal\entity_module_test\Form\EnhancedEntityForm",
* "duplicate" = "\Drupal\entity_module_test\Form\EnhancedEntityForm",
* "delete" = "\Drupal\Core\Entity\EntityDeleteForm",
* },
* "route_provider" = {
......@@ -62,6 +63,7 @@ use Drupal\entity\Revision\RevisionableContentEntityBase;
* "add-page" = "/entity_test_enhanced/add",
* "add-form" = "/entity_test_enhanced/add/{type}",
* "edit-form" = "/entity_test_enhanced/{entity_test_enhanced}/edit",
* "duplicate-form" = "/entity_test_enhanced/{entity_test_enhanced}/duplicate",
* "canonical" = "/entity_test_enhanced/{entity_test_enhanced}",
* "collection" = "/entity_test_enhanced",
* "delete-multiple-form" = "/entity_test_enhanced/delete",
......@@ -85,6 +87,10 @@ class EnhancedEntity extends RevisionableContentEntityBase implements EntityPubl
$fields['name'] = BaseFieldDefinition::create('string')
->setLabel('Name')
->setRevisionable(TRUE)
->setDisplayOptions('form', [
'type' => 'string_textfield',
'weight' => 0,
])
->setDisplayOptions('view', [
'label' => 'hidden',
'type' => 'string',
......
......@@ -29,8 +29,8 @@ use Drupal\user\UserInterface;
* "query_access" = "\Drupal\entity\QueryAccess\UncacheableQueryAccessHandler",
* "permission_provider" = "\Drupal\entity\UncacheableEntityPermissionProvider",
* "form" = {
* "add" = "\Drupal\entity\Form\RevisionableContentEntityForm",
* "edit" = "\Drupal\entity\Form\RevisionableContentEntityForm",
* "add" = "\Drupal\Core\Entity\ContentEntityForm",
* "edit" = "\Drupal\Core\Entity\ContentEntityForm",