diff --git a/core/lib/Drupal/Core/Entity/Controller/EntityController.php b/core/lib/Drupal/Core/Entity/Controller/EntityController.php index e80ad58..f1df4dc 100644 --- a/core/lib/Drupal/Core/Entity/Controller/EntityController.php +++ b/core/lib/Drupal/Core/Entity/Controller/EntityController.php @@ -10,7 +10,11 @@ use Drupal\Core\Entity\EntityRepositoryInterface; use Drupal\Core\Entity\EntityTypeBundleInfoInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; +use Drupal\Core\Link; +use Drupal\Core\Render\RendererInterface; use Drupal\Core\Routing\RouteMatchInterface; +use Drupal\Core\Routing\UrlGeneratorInterface; +use Drupal\Core\Routing\UrlGeneratorTrait; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\StringTranslation\StringTranslationTrait; @@ -18,11 +22,12 @@ use Symfony\Component\DependencyInjection\ContainerInterface; /** - * Provides generic entity title callbacks for use in routing. + * Provides the add-page and title callbacks for entities. * * It provides: - * - An add title callback for entities without bundles. - * - An add title callback for entities with bundles. + * - The add-page callback. + * - An add title callback for entity types. + * - An add title callback for entity types with bundles. * - A view title callback. * - An edit title callback. * - A delete title callback. @@ -30,6 +35,7 @@ class EntityController implements ContainerInjectionInterface { use StringTranslationTrait; + use UrlGeneratorTrait; /** * The entity manager. @@ -53,6 +59,13 @@ class EntityController implements ContainerInjectionInterface { protected $entityRepository; /** + * The renderer. + * + * @var \Drupal\Core\Render\RendererInterface + */ + protected $renderer; + + /** * Constructs a new EntityController. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager @@ -61,14 +74,20 @@ class EntityController implements ContainerInjectionInterface { * The entity type bundle info. * @param \Drupal\Core\Entity\EntityRepositoryInterface $entity_repository * The entity repository. + * @param \Drupal\Core\Render\RendererInterface $renderer + * The renderer. * @param \Drupal\Core\StringTranslation\TranslationInterface $string_translation * The string translation. + * @param \Drupal\Core\Routing\UrlGeneratorInterface $url_generator + * The url generator. */ - public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, EntityRepositoryInterface $entity_repository, TranslationInterface $string_translation) { + public function __construct(EntityTypeManagerInterface $entity_type_manager, EntityTypeBundleInfoInterface $entity_type_bundle_info, EntityRepositoryInterface $entity_repository, RendererInterface $renderer, TranslationInterface $string_translation, UrlGeneratorInterface $url_generator) { $this->entityTypeManager = $entity_type_manager; $this->entityTypeBundleInfo = $entity_type_bundle_info; $this->entityRepository = $entity_repository; + $this->renderer = $renderer; $this->stringTranslation = $string_translation; + $this->urlGenerator = $url_generator; } /** @@ -79,12 +98,68 @@ public static function create(ContainerInterface $container) { $container->get('entity_type.manager'), $container->get('entity_type.bundle.info'), $container->get('entity.repository'), - $container->get('string_translation') + $container->get('renderer'), + $container->get('string_translation'), + $container->get('url_generator') ); } /** - * Provides a generic add title callback for entities without bundles. + * Displays add links for the available bundles. + * + * Redirects to the add form if there's only one bundle available. + * + * @param string $entity_type_id + * The entity type ID. + * + * @return \Symfony\Component\HttpFoundation\RedirectResponse|array + * If there's only one available bundle, a redirect response. + * Otherwise, a render array with the add links for each bundle. + */ + public function addPage($entity_type_id) { + $entity_type = $this->entityTypeManager->getDefinition($entity_type_id); + $bundle_type = $entity_type->getBundleEntityType(); + $bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_type); + $bundle_key = $entity_type->getKey('bundle'); + $form_route_name = 'entity.' . $entity_type_id . '.add_form'; + $build = [ + '#theme' => 'entity_add_list', + '#cache' => [ + 'tags' => $bundle_entity_type->getListCacheTags(), + ], + '#bundle_type' => $bundle_type, + ]; + $bundles = $this->entityTypeBundleInfo->getBundleInfo($entity_type_id); + // Filter out the bundles the user doesn't have access to. + $access_control_handler = $this->entityTypeManager->getAccessControlHandler($bundle_type); + foreach ($bundles as $bundle_name => $bundle_info) { + $access = $access_control_handler->createAccess($bundle_name, NULL, [], TRUE); + if (!$access->isAllowed()) { + unset($bundles[$bundle_name]); + } + $this->renderer->addCacheableDependency($build, $access); + } + // Redirect if there's only one bundle available. + if (count($bundles) == 1) { + $bundle_names = array_keys($bundles); + $bundle_name = reset($bundle_names); + return $this->redirect($form_route_name, [$bundle_key => $bundle_name]); + } + // Prepare the #bundles array for the template. + $bundles = $this->loadBundleDescriptions($bundles, $bundle_type); + foreach ($bundles as $bundle_name => $bundle_info) { + $build['#bundles'][$bundle_name] = [ + 'label' => $bundle_info['label'], + 'description' => $bundle_info['description'], + 'add_link' => Link::createFromRoute($bundle_info['label'], $form_route_name, [$bundle_key => $bundle_name]), + ]; + } + + return $build; + } + + /** + * Provides a generic add title callback for an entity type. * * @param string $entity_type_id * The entity type ID. @@ -209,4 +284,40 @@ protected function doGetEntity(RouteMatchInterface $route_match, EntityInterface } } + /** + * Expands the bundle information with descriptions, if known. + * + * @param array $bundles + * An array of bundle information. + * @param string $bundle_type + * The id of the bundle entity type. + * + * @return array + * The expanded array of bundle information. + */ + protected function loadBundleDescriptions(array $bundles, $bundle_type) { + // Ensure the presence of the description key. + foreach ($bundles as $bundle_name => &$bundle_info) { + $bundle_info['description'] = ''; + } + // Only bundles provided by entity types have descriptions. + if (empty($bundle_type)) { + return $bundles; + } + $bundle_entity_type = $this->entityTypeManager->getDefinition($bundle_type); + if (!$bundle_entity_type->isSubclassOf('\Drupal\Core\Entity\EntityDescriptionInterface')) { + return $bundles; + } + $bundle_names = array_keys($bundles); + /** @var \Drupal\Core\Entity\EntityDescriptionInterface[] $bundle_entities */ + $bundle_entities = $this->entityTypeManager->getStorage($bundle_type)->loadMultiple($bundle_names); + foreach ($bundles as $bundle_name => &$bundle_info) { + if (isset($bundle_entities[$bundle_name])) { + $bundle_info['description'] = $bundle_entities[$bundle_name]->getDescription(); + } + } + + return $bundles; + } + } diff --git a/core/lib/Drupal/Core/Entity/EntityDescriptionInterface.php b/core/lib/Drupal/Core/Entity/EntityDescriptionInterface.php new file mode 100644 index 0000000..03eec65 --- /dev/null +++ b/core/lib/Drupal/Core/Entity/EntityDescriptionInterface.php @@ -0,0 +1,33 @@ +setOption('_admin_route', TRUE); + return $route; + } + } + + /** + * {@inheritdoc} + */ protected function getAddFormRoute(EntityTypeInterface $entity_type) { if ($route = parent::getAddFormRoute($entity_type)) { $route->setOption('_admin_route', TRUE); diff --git a/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php b/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php index 444ee3c..26258bd 100644 --- a/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php +++ b/core/lib/Drupal/Core/Entity/Routing/DefaultHtmlRouteProvider.php @@ -24,6 +24,8 @@ * This class provides the following routes for entities, with title and access * callbacks: * - canonical + * - add-page + * - add-form * - edit-form * - delete-form * @@ -78,8 +80,12 @@ public function getRoutes(EntityTypeInterface $entity_type) { $entity_type_id = $entity_type->id(); - if ($add_route = $this->getAddFormRoute($entity_type)) { - $collection->add("entity.{$entity_type_id}.add_form", $add_route); + if ($add_page_route = $this->getAddPageRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.add_page", $add_page_route); + } + + if ($add_form_route = $this->getAddFormRoute($entity_type)) { + $collection->add("entity.{$entity_type_id}.add_form", $add_form_route); } if ($canonical_route = $this->getCanonicalRoute($entity_type)) { @@ -98,6 +104,29 @@ public function getRoutes(EntityTypeInterface $entity_type) { } /** + * Gets the add page route. + * + * Built only for entity types that have bundles. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type + * The entity type. + * + * @return \Symfony\Component\Routing\Route|null + * The generated route, if available. + */ + protected function getAddPageRoute(EntityTypeInterface $entity_type) { + if ($entity_type->hasLinkTemplate('add-page') && $entity_type->getKey('bundle')) { + $route = new Route($entity_type->getLinkTemplate('add-page')); + $route->setDefault('_controller', EntityController::class . '::addPage'); + $route->setDefault('_title_callback', EntityController::class . '::addTitle'); + $route->setDefault('entity_type_id', $entity_type->id()); + $route->setRequirement('_entity_create_access', $entity_type->id()); + + return $route; + } + } + + /** * Gets the add-form route. * * @param \Drupal\Core\Entity\EntityTypeInterface $entity_type diff --git a/core/modules/system/src/Tests/Entity/EntityAddUITest.php b/core/modules/system/src/Tests/Entity/EntityAddUITest.php new file mode 100644 index 0000000..7718f3b --- /dev/null +++ b/core/modules/system/src/Tests/Entity/EntityAddUITest.php @@ -0,0 +1,77 @@ +drupalCreateUser([ + "administer entity_test_with_bundle content", + ]); + $this->drupalLogin($web_user); + } + + public function testAddPage() { + $this->drupalGet('/entity_test_with_bundle/add'); + // As long we don't have any bundles yet, there is some automatic redirect. + $this->assertUrl('/entity_test_with_bundle/add/entity_test_with_bundle'); + + EntityTestBundle::create([ + 'id' => 'test', + 'label' => 'Test label', + 'description' => 'My test description', + ])->save(); + + $this->drupalGet('/entity_test_with_bundle/add'); + $this->assertUrl('/entity_test_with_bundle/add'); + $this->assertLink('Test label'); + $this->assertText('My test description'); + + EntityTestBundle::create([ + 'id' => 'test2', + 'label' => 'Test2 label', + 'description' => 'My test2 description', + ])->save(); + $this->drupalGet('/entity_test_with_bundle/add'); + $this->assertUrl('/entity_test_with_bundle/add'); + + $this->assertLink('Entity Test Bundle'); + $this->assertLink('Test label'); + $this->assertLink('Test2 label'); + $this->assertText('My test description'); + $this->assertText('My test2 description'); + + $this->clickLink('Test label'); + $this->drupalGet('/entity_test_with_bundle/add/test'); + + $this->drupalPostForm(NULL, ['name[0][value]' => 'test name'], t('Save')); + + $entity = EntityTestWithBundle::load(1); + $this->assertEqual('test name', $entity->name->value); + } + +} diff --git a/core/modules/system/system.module b/core/modules/system/system.module index 2572a39..7f0a0f6 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -217,6 +217,13 @@ function system_theme() { 'variables' => array('menu_items' => NULL), 'file' => 'system.admin.inc', ), + 'entity_add_list' => array( + 'variables' => array( + 'bundles' => array(), + 'bundle_type' => NULL, + ), + 'template' => 'entity-add-list', + ), )); } @@ -317,6 +324,30 @@ function system_theme_suggestions_field(array $variables) { } /** + * Prepares variables for the list of available bundles. + * + * Default template: entity-add-list.html.twig. + * + * @param array $variables + * An associative array containing: + * - bundle_type: The entity type of the bundles. + * - bundles: An array of bundles with the label, description, add_link keys. + */ +function template_preprocess_entity_add_list(&$variables) { + $bundle_type = \Drupal::entityTypeManager()->getDefinition($variables['bundle_type']); + $variables += [ + 'create_bundle_url' => Url::fromRoute('entity.' . $bundle_type->id() . '.add_form')->toString(), + 'bundle_type_label' => $bundle_type->getLowercaseLabel(), + ]; + + foreach ($variables['bundles'] as $bundle_name => $bundle_info) { + $variables['bundles'][$bundle_name]['description'] = [ + '#markup' => $bundle_info['description'], + ]; + } +} + +/** * @defgroup authorize Authorized operations * @{ * Functions to run operations with elevated privileges via authorize.php. diff --git a/core/modules/system/templates/entity-add-list.html.twig b/core/modules/system/templates/entity-add-list.html.twig new file mode 100644 index 0000000..55970ef --- /dev/null +++ b/core/modules/system/templates/entity-add-list.html.twig @@ -0,0 +1,31 @@ +{# +/** + * @file + * Default theme implementation to present a list of available bundles. + * + * Available variables: + * - create_bundle_url: The url to the bundle creation page. + * - bundle_type_label: The lowercase label of the bundle entity type. + * - bundles: A list of bundles, each with the following properties: + * - add_link: link to create an entity of this bundle. + * - description: Bundle description. + * + * @see template_preprocess_entity_add_list() + * + * @ingroup themeable + */ +#} +{% if bundles is not empty %} +
+ {% for bundle in bundles %} +
{{ bundle.add_link }}
+
{{ bundle.description }}
+ {% endfor %} +
+{% else %} +

+ {% trans %} + Go to the {{ bundle_type_label }} creation page to add a new {{ bundle_type_label }}. + {% endtrans %} +

+{% endif %} diff --git a/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml b/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml index d671bc4..d34d949 100644 --- a/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml +++ b/core/modules/system/tests/modules/entity_test/config/schema/entity_test.schema.yml @@ -13,3 +13,17 @@ field.storage_settings.shape: foreign_key_name: type: string label: 'Foreign key name' + +entity_test.entity_test_bundle.*: + type: config_entity + label: 'Entity test bundle' + mapping: + label: + type: label + label: 'Label' + id: + type: string + label: 'Machine-readable name' + description: + type: text + label: 'Description' diff --git a/core/modules/system/tests/modules/entity_test/entity_test.module b/core/modules/system/tests/modules/entity_test/entity_test.module index 668c81d..17ac84d 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.module +++ b/core/modules/system/tests/modules/entity_test/entity_test.module @@ -196,7 +196,7 @@ function entity_test_entity_bundle_info() { $bundles = array(); $entity_types = \Drupal::entityManager()->getDefinitions(); foreach ($entity_types as $entity_type_id => $entity_type) { - if ($entity_type->getProvider() == 'entity_test') { + if ($entity_type->getProvider() == 'entity_test' && $entity_type_id != 'entity_test_with_bundle') { $bundles[$entity_type_id] = \Drupal::state()->get($entity_type_id . '.bundles') ?: array($entity_type_id => array('label' => 'Entity Test Bundle')); } } diff --git a/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml b/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml index ff9810b..eb0d974 100644 --- a/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml +++ b/core/modules/system/tests/modules/entity_test/entity_test.permissions.yml @@ -7,3 +7,6 @@ view test entity translations: title: 'View translations of test entities' view test entity field: title: 'View test entity field' +administer entity_test_with_bundle content: + title: 'administer entity_test_with_bundle content' + description: 'administer entity_test_with_bundle content' diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestBundle.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestBundle.php new file mode 100644 index 0000000..72a0033 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestBundle.php @@ -0,0 +1,83 @@ +description; + } + + /** + * {@inheritdoc} + */ + public function setDescription($description) { + $this->description = $description; + return $this; + } + +} diff --git a/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php new file mode 100644 index 0000000..0ee8bb7 --- /dev/null +++ b/core/modules/system/tests/modules/entity_test/src/Entity/EntityTestWithBundle.php @@ -0,0 +1,77 @@ +setLabel(t('Name')) + ->setDescription(t('The name of the test entity.')) + ->setTranslatable(TRUE) + ->setSetting('max_length', 32) + ->setDisplayOptions('view', [ + 'label' => 'hidden', + 'type' => 'string', + 'weight' => -5, + ]) + ->setDisplayOptions('form', [ + 'type' => 'string_textfield', + 'weight' => -5, + ]); + return $fields; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Entity/Routing/DefaultHtmlRouteProviderTest.php b/core/tests/Drupal/Tests/Core/Entity/Routing/DefaultHtmlRouteProviderTest.php index a1d04e1..54393d4 100644 --- a/core/tests/Drupal/Tests/Core/Entity/Routing/DefaultHtmlRouteProviderTest.php +++ b/core/tests/Drupal/Tests/Core/Entity/Routing/DefaultHtmlRouteProviderTest.php @@ -59,6 +59,44 @@ protected function setUp() { } /** + * @covers ::getAddPageRoute + * @dataProvider providerTestGetAddPageRoute + */ + public function testGetAddPageRoute(Route $expected = NULL, EntityTypeInterface $entity_type) { + $route = $this->routeProvider->getAddPageRoute($entity_type); + $this->assertEquals($expected, $route); + } + + public function providerTestGetAddPageRoute() { + $data = []; + + $entity_type1 = $this->getEntityType(); + $entity_type1->hasLinkTemplate('add-page')->willReturn(FALSE); + $data['no_add_page_link_template'] = [NULL, $entity_type1->reveal()]; + + $entity_type2 = $this->getEntityType(); + $entity_type2->hasLinkTemplate('add-page')->willReturn(TRUE); + $entity_type2->getKey('bundle')->willReturn(NULL); + $data['no_bundle'] = [NULL, $entity_type2->reveal()]; + + $entity_type3 = $this->getEntityType(); + $entity_type3->hasLinkTemplate('add-page')->willReturn(TRUE); + $entity_type3->getLinkTemplate('add-page')->willReturn('/the/add/page/link/template'); + $entity_type3->id()->willReturn('the_entity_type_id'); + $entity_type3->getKey('bundle')->willReturn('type'); + $route = new Route('/the/add/page/link/template'); + $route->setDefaults([ + '_controller' => 'Drupal\Core\Entity\Controller\EntityController::addPage', + '_title_callback' => 'Drupal\Core\Entity\Controller\EntityController::addTitle', + 'entity_type_id' => 'the_entity_type_id', + ]); + $route->setRequirement('_entity_create_access', 'the_entity_type_id'); + $data['add_page'] = [clone $route, $entity_type3->reveal()]; + + return $data; + } + + /** * @covers ::getAddFormRoute * @dataProvider providerTestGetAddFormRoute */ @@ -260,6 +298,9 @@ class TestDefaultHtmlRouteProvider extends DefaultHtmlRouteProvider { public function getEntityTypeIdKeyType(EntityTypeInterface $entity_type) { return parent::getEntityTypeIdKeyType($entity_type); } + public function getAddPageRoute(EntityTypeInterface $entity_type) { + return parent::getAddPageRoute($entity_type); + } public function getAddFormRoute(EntityTypeInterface $entity_type) { return parent::getAddFormRoute($entity_type); } diff --git a/core/themes/seven/templates/entity-add-list.html.twig b/core/themes/seven/templates/entity-add-list.html.twig new file mode 100644 index 0000000..145f858 --- /dev/null +++ b/core/themes/seven/templates/entity-add-list.html.twig @@ -0,0 +1,28 @@ +{# +/** + * @file + * Theme override to to present a list of available bundles. + * + * Available variables: + * - create_bundle_url: The url to the bundle creation page. + * - bundle_type_label: The lowercase label of the bundle entity type. + * - bundles: A list of bundles, each with the following properties: + * - add_link: link to create an entity of this bundle. + * - description: Bundle description. + * + * @see template_preprocess_entity_add_list() + */ +#} +{% if bundles is not empty %} + +{% else %} +

+ {% trans %} + Go to the {{ bundle_type_label }} creation page to add a new {{ bundle_type_label }}. + {% endtrans %} +

+{% endif %}