diff --git a/core/modules/media/media.module b/core/modules/media/media.module index 296945973ae5eae6f05cf833894fa4cf7cf22e8c..5079f0fa9a868bb9a76bbdcf4ecd19fd2d9e6b51 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -9,8 +9,10 @@ use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Render\Element; +use Drupal\Core\Render\Element\RenderElement; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Session\AccountInterface; +use Drupal\Core\Template\Attribute; use Drupal\Core\Url; use Drupal\field\FieldConfigInterface; @@ -66,6 +68,10 @@ function media_theme() { 'media' => [ 'render element' => 'elements', ], + 'media_reference_help' => [ + 'render element' => 'element', + 'base hook' => 'field_multiple_value_form', + ], ]; } @@ -172,3 +178,128 @@ function media_form_field_ui_field_storage_add_form_alter(&$form, FormStateInter $form['add']['new_storage_type']['#weight'] = 0; $form['add']['description_wrapper']['#weight'] = 1; } + +/** + * Implements hook_field_widget_multivalue_form_alter(). + */ +function media_field_widget_multivalue_form_alter(array &$elements, FormStateInterface $form_state, array $context) { + // Do not alter the default settings form. + if ($context['default']) { + return; + } + + // Only act on entity reference fields that reference media. + $field_type = $context['items']->getFieldDefinition()->getType(); + $target_type = $context['items']->getFieldDefinition()->getFieldStorageDefinition()->getSetting('target_type'); + if ($field_type !== 'entity_reference' || $target_type !== 'media') { + return; + } + + // Autocomplete widgets need different help text than options widgets. + $widget_plugin_id = $context['widget']->getPluginId(); + if (in_array($widget_plugin_id, ['entity_reference_autocomplete', 'entity_reference_autocomplete_tags'])) { + $is_autocomplete = TRUE; + } + else { + // @todo We can't yet properly alter non-autocomplete fields. Resolve this + // in https://www.drupal.org/node/2943020 and remove this condition. + return; + } + $elements['#media_help'] = []; + + // Retrieve the media bundle list and add information for the user based on + // which bundles are available to be created or referenced. + $settings = $context['items']->getFieldDefinition()->getSetting('handler_settings'); + $allowed_bundles = isset($settings['target_bundles']) ? $settings['target_bundles'] : []; + $access_handler = \Drupal::entityTypeManager()->getAccessControlHandler('media'); + $all_bundles = \Drupal::service('entity_type.bundle.info')->getBundleInfo('media'); + $bundle_labels = []; + $create_bundles = []; + foreach ($allowed_bundles as $bundle) { + $bundle_labels[] = $all_bundles[$bundle]['label']; + if ($access_handler->createAccess($bundle)) { + $create_bundles[] = $bundle; + if (count($create_bundles) > 1) { + // If the user has access to create more than 1 bundle then the + // individual media type form can not be used. + break; + } + } + } + + // Add a section about how to create media if the user has access to do so. + if (!empty($create_bundles)) { + if (count($create_bundles) === 1) { + $add_url = Url::fromRoute('entity.media.add_form', ['media_type' => $create_bundles[0]])->toString(); + } + elseif (count($create_bundles) > 1) { + $add_url = Url::fromRoute('entity.media.add_page')->toString(); + } + $elements['#media_help']['#media_add_help'] = t('Create your media on the media add page (opens a new window), then add it by name to the field below.', [':add_page' => $add_url]); + } + + $elements['#theme'] = 'media_reference_help'; + // @todo template_preprocess_field_multiple_value_form() assumes this key + // exists, but it does not exist in the case of a single widget that + // accepts multiple values. This is for some reason necessary to use + // our template for the entity_autocomplete_tags widget. + // Research and resolve this in https://www.drupal.org/node/2943020. + if (empty($elements['#cardinality_multiple'])) { + $elements['#cardinality_multiple'] = NULL; + } + + // Use the title set on the element if it exists, otherwise fall back to the + // field label. + $elements['#media_help']['#original_label'] = isset($elements['#title']) ? $elements['#title'] : $context['items']->getFieldDefinition()->getLabel(); + + // Customize the label for the field widget. + // @todo Research a better approach https://www.drupal.org/node/2943024. + $use_existing_label = t('Use existing media'); + if (!empty($elements[0]['target_id']['#title'])) { + $elements[0]['target_id']['#title'] = $use_existing_label; + } + if (!empty($elements['#title'])) { + $elements['#title'] = $use_existing_label; + } + if (!empty($elements['target_id']['#title'])) { + $elements['target_id']['#title'] = $use_existing_label; + } + + // This help text is only relevant for autocomplete widgets. When the user + // is presented with options, they don't need to type anything or know what + // types of media are allowed. + if ($is_autocomplete) { + $elements['#media_help']['#media_list_help'] = t('Type part of the media name.'); + + $overview_url = Url::fromRoute('entity.media.collection'); + if ($overview_url->access()) { + $elements['#media_help']['#media_list_link'] = t('See the media list (opens a new window) to help locate media.', [':list_url' => $overview_url->toString()]); + } + $elements['#media_help']['#allowed_types_help'] = t('Allowed media types: %types', ['%types' => implode(", ", $bundle_labels)]); + } +} + +/** + * Implements hook_preprocess_HOOK() for media reference widgets. + */ +function media_preprocess_media_reference_help(&$variables) { + // Most of these attribute checks are copied from + // template_preprocess_fieldset(). Our template extends + // field-multiple-value-form.html.twig to provide our help text, but also + // groups the information within a semantic fieldset with a legend. So, we + // incorporate parity for both. + $element = $variables['element']; + Element::setAttributes($element, ['id']); + RenderElement::setAttributes($element); + $variables['attributes'] = isset($element['#attributes']) ? $element['#attributes'] : []; + $variables['legend_attributes'] = new Attribute(); + $variables['header_attributes'] = new Attribute(); + $variables['description']['attributes'] = new Attribute(); + $variables['legend_span_attributes'] = new Attribute(); + + if (!empty($element['#media_help'])) { + foreach ($element['#media_help'] as $key => $text) { + $variables[substr($key, 1)] = $text; + } + } +} diff --git a/core/modules/media/templates/media-reference-help.html.twig b/core/modules/media/templates/media-reference-help.html.twig new file mode 100644 index 0000000000000000000000000000000000000000..9243c5d4fde6a61e2d296998fcc7244ef0b2e9b7 --- /dev/null +++ b/core/modules/media/templates/media-reference-help.html.twig @@ -0,0 +1,66 @@ +{# +/** + * @file + * Theme override for media reference fields. + * + * @see template_preprocess_field_multiple_value_form() + * @see core/themes/classy/templates/form/fieldset.html.twig + */ +#} +{% + set classes = [ + 'js-form-item', + 'form-item', + 'js-form-wrapper', + 'form-wrapper', + ] +%} + + {% + set legend_span_classes = [ + 'fieldset-legend', + required ? 'js-form-required', + required ? 'form-required', + ] + %} + {# Always wrap fieldset legends in a for CSS positioning. #} + + {{ original_label }} + + +
+ {% if media_add_help %} + + {% trans %} + Create new media + {% endtrans %} +
+
+ {{ media_add_help }} +
+ {% endif %} + + {% if multiple %} + {{ table }} + {% else %} + {% for element in elements %} + {{ element }} + {% endfor %} + {% endif %} + + + {% if multiple and description.content %} +
    +
  • {{ media_list_help }} {{ media_list_link }} {{ allowed_types_help }}
  • +
  • {{ description.content }}
  • +
+ {% else %} + {{ media_list_help }} {{ media_list_link }} {{ allowed_types_help }} + {% endif %} + {% if multiple and button %} +
{{ button }}
+ {% endif %} +
+ + + diff --git a/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php b/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php index 224bb426e38227c62e683e5e49ea48f248754692..379ba74717911f23b95494a476b209c2845ad166 100644 --- a/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php +++ b/core/modules/media/tests/src/Functional/MediaFunctionalTestTrait.php @@ -14,6 +14,7 @@ trait MediaFunctionalTestTrait { */ protected static $adminUserPermissions = [ // Media module permissions. + 'access media overview', 'administer media', 'administer media fields', 'administer media form display', diff --git a/core/modules/media/tests/src/Functional/MediaUiFunctionalTest.php b/core/modules/media/tests/src/Functional/MediaUiFunctionalTest.php index 8d876b7b0d7b8c7dfe83fe1f3a13a9432b404545..c6c1c956be1796fe804760df14cc8023f15521b6 100644 --- a/core/modules/media/tests/src/Functional/MediaUiFunctionalTest.php +++ b/core/modules/media/tests/src/Functional/MediaUiFunctionalTest.php @@ -2,7 +2,12 @@ namespace Drupal\Tests\media\Functional; +use Behat\Mink\Element\NodeElement; use Drupal\media\Entity\Media; +use Drupal\Core\Field\FieldStorageDefinitionInterface; +use Drupal\Core\Url; +use Drupal\field\Entity\FieldConfig; +use Drupal\field\Entity\FieldStorageConfig; /** * Ensures that media UI works correctly. @@ -192,6 +197,308 @@ public function testRenderedEntityReferencedMedia() { $assert_session->fieldValueEquals('fields[field_foo_field][type]', 'entity_reference_entity_view'); } + /** + * Data provider for testMediaReferenceWidget(). + * + * @return array[] + * Test data. See testMediaReferenceWidget() for the child array structure. + */ + public function providerTestMediaReferenceWidget() { + return [ + // Single-value fields with a single media type and the default widget: + // - The user can create and list the media. + 'single_value:single_type:create_list' => [1, [TRUE], TRUE], + // - The user can list but not create the media. + 'single_value:single_type:list' => [1, [FALSE], TRUE], + // - The user can create but not list the media. + 'single_value:single_type:create' => [1, [TRUE], FALSE], + // - The user can neither create nor list the media. + 'single_value:single_type' => [1, [FALSE], FALSE], + + // Single-value fields with the tags-style widget: + // - The user can create and list the media. + 'single_value:single_type:create_list:tags' => [1, [TRUE], TRUE, 'entity_reference_autocomplete_tags'], + // - The user can list but not create the media. + 'single_value:single_type:list:tags' => [1, [FALSE], TRUE, 'entity_reference_autocomplete_tags'], + // - The user can create but not list the media. + 'single_value:single_type:create:tags' => [1, [TRUE], FALSE, 'entity_reference_autocomplete_tags'], + // - The user can neither create nor list the media. + 'single_value:single_type:tags' => [1, [FALSE], FALSE, 'entity_reference_autocomplete_tags'], + + // Single-value fields with two media types: + // - The user can create both types. + 'single_value:two_type:create2_list' => [1, [TRUE, TRUE], TRUE], + // - The user can create only one type. + 'single_value:two_type:create1_list' => [1, [TRUE, FALSE], TRUE], + // - The user cannot create either type. + 'single_value:two_type:list' => [1, [FALSE, FALSE], TRUE], + + // Multiple-value field with a cardinality of 3, with media the user can + // create and list. + 'multi_value:single_type:create_list' => [3, [TRUE], TRUE], + // The same, with the tags field. + 'multi-value:single_type:create_list:tags' => [3, [TRUE], TRUE, 'entity_reference_autocomplete_tags'], + + // Unlimited value field. + 'unlimited_value:single_type:create_list' => [FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, [TRUE], TRUE], + // Unlimited value field with the tags widget. + 'unlimited_value:single_type:create_list' => [FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, [TRUE], TRUE, 'entity_reference_autocomplete_tags'], + ]; + } + + /** + * Tests the default autocomplete widgets for media reference fields. + * + * @param int $cardinality + * The field cardinality. + * @param bool[] $media_type_create_access + * An array of booleans indicating whether to grant the test user create + * access for each media type. A media type is created automatically for + * each; for example, an array [TRUE, FALSE] would create two media types, + * one that allows the user to create media and a second that does not. + * @param bool $list_access + * Whether to grant the test user access to list media. + * + * @see media_field_widget_entity_reference_autocomplete_form_alter() + * @see media_field_widget_multiple_entity_reference_autocomplete_form_alter() + * + * @dataProvider providerTestMediaReferenceWidget + */ + public function testMediaReferenceWidget($cardinality, array $media_type_create_access, $list_access, $widget_id = 'entity_reference_autocomplete') { + $assert_session = $this->assertSession(); + + // Create two content types. + $non_media_content_type = $this->createContentType(); + $content_type = $this->createContentType(); + + // Create some media types. + $media_types = []; + $permissions = []; + $create_media_types = []; + foreach ($media_type_create_access as $id => $access) { + if ($access) { + $create_media_types[] = "media_type_$id"; + $permissions[] = "create media_type_$id media"; + } + $this->createMediaType(['bundle' => "media_type_$id"]); + $media_types["media_type_$id"] = "media_type_$id"; + } + + // Create a user that can create content of the type, with other + // permissions as given by the data provider. + $permissions[] = "create {$content_type->id()} content"; + if ($list_access) { + $permissions[] = "access media overview"; + } + $test_user = $this->drupalCreateUser($permissions); + + // Create a non-media entity reference. + $non_media_storage = FieldStorageConfig::create([ + 'field_name' => 'field_not_a_media_field', + 'entity_type' => 'node', + 'type' => 'entity_reference', + 'cardinality' => FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED, + 'settings' => [ + 'target_type' => 'node', + ], + ]); + $non_media_storage->save(); + $non_media_field = FieldConfig::create([ + 'label' => 'No media here!', + 'field_storage' => $non_media_storage, + 'entity_type' => 'node', + 'bundle' => $non_media_content_type->id(), + 'settings' => [ + 'handler' => 'default', + 'handler_settings' => [ + 'target_bundles' => [ + $non_media_content_type->id() => $non_media_content_type->id(), + ], + ], + ], + ]); + $non_media_field->save(); + \Drupal::entityTypeManager() + ->getStorage('entity_form_display') + ->load('node.' . $non_media_content_type->id() . '.default') + ->setComponent('field_not_a_media_field', [ + 'type' => $widget_id, + ]) + ->save(); + + // Create a media field through the user interface to ensure that the + // help text handling does not break the default value entry on the field + // settings form. + // Using drupalPostForm() to avoid dealing with JavaScript on the previous + // page in the field creation. + $edit = [ + 'new_storage_type' => 'field_ui:entity_reference:media', + 'label' => "Media (cardinality $cardinality)", + 'field_name' => 'media_reference', + ]; + $this->drupalPostForm("admin/structure/types/manage/{$content_type->id()}/fields/add-field", $edit, 'Save and continue'); + $edit = []; + foreach ($media_types as $type) { + $edit["settings[handler_settings][target_bundles][$type]"] = TRUE; + } + $this->drupalPostForm("admin/structure/types/manage/{$content_type->id()}/fields/node.{$content_type->id()}.field_media_reference", $edit, "Save settings"); + \Drupal::entityTypeManager() + ->getStorage('entity_form_display') + ->load('node.' . $content_type->id() . '.default') + ->setComponent('field_media_reference', [ + 'type' => $widget_id, + ]) + ->save(); + + // Some of the expected texts. + $create_help = 'Create your media on the media add page (opens a new window), then add it by name to the field below.'; + $list_text = 'See the media list (opens a new window) to help locate media.'; + $use_help = 'Type part of the media name.'; + $create_header = "Create new media"; + $use_header = "Use existing media"; + + // First check that none of the help texts are on the non-media content. + $this->drupalGet("/node/add/{$non_media_content_type->id()}"); + $this->assertNoHelpTexts([ + $create_header, + $create_help, + $use_header, + $use_help, + $list_text, + 'Allowed media types:', + ]); + + // Now, check that the widget displays the expected help text under the + // given conditions for the test user. + $this->drupalLogin($test_user); + $this->drupalGet("/node/add/{$content_type->id()}"); + + // Specific expected help texts for the media field. + $create_header = "Create new media"; + $use_header = "Use existing media"; + $type_list = 'Allowed media types: ' . implode(", ", array_keys($media_types)); + + $fieldset_selector = '#edit-field-media-reference-wrapper fieldset'; + $fieldset = $assert_session->elementExists('css', $fieldset_selector); + + $this->assertSame("Media (cardinality $cardinality)", $assert_session->elementExists('css', 'legend', $fieldset)->getText()); + + // Assert text that should be displayed regardless of other access. + $this->assertHelpTexts([$use_header, $use_help, $type_list], $fieldset_selector); + + // The entire section for creating new media should only be displayed if + // the user can create at least one media of the type. + if ($create_media_types) { + if (count($create_media_types) === 1) { + $url = Url::fromRoute('entity.media.add_form')->setRouteParameter('media_type', $create_media_types[0]); + } + else { + $url = Url::fromRoute('entity.media.add_page'); + } + $this->assertHelpTexts([$create_header, $create_help], $fieldset_selector); + $this->assertHelpLink( + $fieldset, + 'media add page', + [ + 'target' => '_blank', + 'href' => $url->toString(), + ] + ); + } + else { + $this->assertNoHelpTexts([$create_header, $create_help]); + $this->assertNoHelpLink($fieldset, 'media add page'); + } + + if ($list_access) { + $this->assertHelpTexts([$list_text], $fieldset_selector); + $this->assertHelpLink( + $fieldset, + 'media list', + [ + 'target' => '_blank', + 'href' => Url::fromRoute('entity.media.collection')->toString(), + ] + ); + } + else { + $this->assertNoHelpTexts([$list_text]); + $this->assertNoHelpLink($fieldset, 'media list'); + } + } + + /** + * Asserts that the given texts are present exactly once. + * + * @param string[] $texts + * A list of the help texts to check. + * @param string $selector + * (optional) The selector to search. + */ + public function assertHelpTexts(array $texts, $selector = '') { + $assert_session = $this->assertSession(); + foreach ($texts as $text) { + // We only want to escape single quotes, so use str_replace() rather than + // addslashes(). + $text = str_replace("'", "\'", $text); + if ($selector) { + $assert_session->elementsCount('css', $selector . ":contains('$text')", 1); + } + else { + $assert_session->pageTextContains($text); + } + } + } + + /** + * Asserts that none of the given texts are present. + * + * @param string[] $texts + * A list of the help texts to check. + */ + public function assertNoHelpTexts(array $texts) { + $assert_session = $this->assertSession(); + foreach ($texts as $text) { + $assert_session->pageTextNotContains($text); + } + } + + /** + * Asserts whether a given link is present. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to search. + * @param string $text + * The link text. + * @param string[] $attributes + * An associative array of any expected attributes, keyed by the + * attribute name. + */ + protected function assertHelpLink(NodeElement $element, $text, array $attributes = []) { + // Find all the links inside the element. + $link = $element->findLink($text); + + $this->assertNotEmpty($link); + foreach ($attributes as $attribute => $value) { + $this->assertEquals($link->getAttribute($attribute), $value); + } + } + + /** + * Asserts that a given link is not present. + * + * @param \Behat\Mink\Element\NodeElement $element + * The element to search. + * @param string $text + * The link text. + */ + protected function assertNoHelpLink(NodeElement $element, $text) { + $assert_session = $this->assertSession(); + // Assert that the link and its text are not present anywhere on the page. + $assert_session->elementNotExists('named', ['link', $text], $element); + $assert_session->pageTextNotContains($text); + } + /** * Test the media collection route. */