From 00c2069f57f78f9d25092129065fefed93f56e92 Mon Sep 17 00:00:00 2001 From: Alex Pott Date: Thu, 21 Jun 2018 09:11:10 +0100 Subject: [PATCH 01/39] =?UTF-8?q?Issue=20#2831944=20by=20chr.fritsch,=20ph?= =?UTF-8?q?enaproxima,=20alexpott,=20marcoscano,=20slashrsm,=20seanB,=20ro?= =?UTF-8?q?bpowell,=20samuel.mortenson,=20tstoeckler,=20Wim=20Leers,=20daw?= =?UTF-8?q?ehner,=20martin107,=20G=C3=A1bor=20Hojtsy,=20aheimlich,=20tedbo?= =?UTF-8?q?w,=20mtodor,=20larowlan,=20idebr,=20bkosborne,=20ckrina:=20Impl?= =?UTF-8?q?ement=20media=20source=20plugin=20for=20remote=20video=20via=20?= =?UTF-8?q?oEmbed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/config/install/media.settings.yml | 2 + .../media/config/schema/media.schema.yml | 35 ++ core/modules/media/media.api.php | 17 + core/modules/media/media.info.yml | 1 + core/modules/media/media.install | 43 ++ core/modules/media/media.links.menu.yml | 6 + core/modules/media/media.module | 33 ++ core/modules/media/media.routing.yml | 15 + core/modules/media/media.services.yml | 13 +- .../src/Controller/OEmbedIframeController.php | 174 ++++++ core/modules/media/src/Entity/Media.php | 153 +++-- .../media/src/Form/MediaSettingsForm.php | 108 ++++ core/modules/media/src/IFrameMarkup.php | 24 + core/modules/media/src/IFrameUrlHelper.php | 84 +++ core/modules/media/src/MediaSourceBase.php | 4 +- core/modules/media/src/OEmbed/Endpoint.php | 176 ++++++ core/modules/media/src/OEmbed/Provider.php | 100 ++++ .../media/src/OEmbed/ProviderException.php | 40 ++ .../media/src/OEmbed/ProviderRepository.php | 122 ++++ .../OEmbed/ProviderRepositoryInterface.php | 40 ++ core/modules/media/src/OEmbed/Resource.php | 534 ++++++++++++++++++ .../media/src/OEmbed/ResourceException.php | 67 +++ .../media/src/OEmbed/ResourceFetcher.php | 197 +++++++ .../src/OEmbed/ResourceFetcherInterface.php | 32 ++ core/modules/media/src/OEmbed/UrlResolver.php | 187 ++++++ .../media/src/OEmbed/UrlResolverInterface.php | 45 ++ .../Field/FieldFormatter/OEmbedFormatter.php | 303 ++++++++++ .../Plugin/Field/FieldWidget/OEmbedWidget.php | 64 +++ .../Constraint/OEmbedResourceConstraint.php | 50 ++ .../OEmbedResourceConstraintValidator.php | 144 +++++ .../media/src/Plugin/media/Source/OEmbed.php | 466 +++++++++++++++ .../src/Plugin/media/Source/OEmbedDeriver.php | 32 ++ .../Plugin/media/Source/OEmbedInterface.php | 23 + .../templates/media-oembed-frame.html.twig | 14 + .../tests/fixtures/oembed/photo_flickr.html | 8 + .../tests/fixtures/oembed/photo_flickr.json | 12 + .../tests/fixtures/oembed/providers.json | 61 ++ .../tests/fixtures/oembed/rich_twitter.json | 13 + .../fixtures/oembed/video_collegehumor.html | 8 + .../fixtures/oembed/video_collegehumor.xml | 18 + .../tests/fixtures/oembed/video_vimeo.html | 8 + .../tests/fixtures/oembed/video_vimeo.json | 16 + .../media_test_oembed.info.yml | 8 + .../media_test_oembed.module | 17 + .../media_test_oembed.routing.yml | 6 + .../src/Controller/ResourceController.php | 48 ++ .../src/MediaTestOembedServiceProvider.php | 26 + .../src/ProviderRepository.php | 55 ++ .../media_test_oembed/src/UrlResolver.php | 38 ++ .../FieldFormatter/OEmbedFormatterTest.php | 173 ++++++ .../src/Functional/MediaSettingsTest.php | 35 ++ .../MediaTemplateSuggestionsTest.php | 2 +- .../src/Functional/ProviderRepositoryTest.php | 89 +++ .../src/Functional/ResourceFetcherTest.php | 72 +++ .../src/Functional/Update/MediaUpdateTest.php | 21 + .../tests/src/Functional/UrlResolverTest.php | 133 +++++ .../FunctionalJavascript/MediaDisplayTest.php | 1 + .../MediaSourceOEmbedVideoTest.php | 190 +++++++ .../src/Kernel/OEmbedIframeControllerTest.php | 56 ++ .../tests/src/Traits/OEmbedTestTrait.php | 89 +++ .../tests/src/Unit/IFrameUrlHelperTest.php | 89 +++ .../content/media-oembed-iframe.html.twig | 14 + 62 files changed, 4601 insertions(+), 53 deletions(-) create mode 100644 core/modules/media/src/Controller/OEmbedIframeController.php create mode 100644 core/modules/media/src/Form/MediaSettingsForm.php create mode 100644 core/modules/media/src/IFrameMarkup.php create mode 100644 core/modules/media/src/IFrameUrlHelper.php create mode 100644 core/modules/media/src/OEmbed/Endpoint.php create mode 100644 core/modules/media/src/OEmbed/Provider.php create mode 100644 core/modules/media/src/OEmbed/ProviderException.php create mode 100644 core/modules/media/src/OEmbed/ProviderRepository.php create mode 100644 core/modules/media/src/OEmbed/ProviderRepositoryInterface.php create mode 100644 core/modules/media/src/OEmbed/Resource.php create mode 100644 core/modules/media/src/OEmbed/ResourceException.php create mode 100644 core/modules/media/src/OEmbed/ResourceFetcher.php create mode 100644 core/modules/media/src/OEmbed/ResourceFetcherInterface.php create mode 100644 core/modules/media/src/OEmbed/UrlResolver.php create mode 100644 core/modules/media/src/OEmbed/UrlResolverInterface.php create mode 100644 core/modules/media/src/Plugin/Field/FieldFormatter/OEmbedFormatter.php create mode 100644 core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php create mode 100644 core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php create mode 100644 core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraintValidator.php create mode 100644 core/modules/media/src/Plugin/media/Source/OEmbed.php create mode 100644 core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php create mode 100644 core/modules/media/src/Plugin/media/Source/OEmbedInterface.php create mode 100644 core/modules/media/templates/media-oembed-frame.html.twig create mode 100644 core/modules/media/tests/fixtures/oembed/photo_flickr.html create mode 100644 core/modules/media/tests/fixtures/oembed/photo_flickr.json create mode 100644 core/modules/media/tests/fixtures/oembed/providers.json create mode 100644 core/modules/media/tests/fixtures/oembed/rich_twitter.json create mode 100644 core/modules/media/tests/fixtures/oembed/video_collegehumor.html create mode 100644 core/modules/media/tests/fixtures/oembed/video_collegehumor.xml create mode 100644 core/modules/media/tests/fixtures/oembed/video_vimeo.html create mode 100644 core/modules/media/tests/fixtures/oembed/video_vimeo.json create mode 100644 core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml create mode 100644 core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module create mode 100644 core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml create mode 100644 core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php create mode 100644 core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php create mode 100644 core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php create mode 100644 core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php create mode 100644 core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php create mode 100644 core/modules/media/tests/src/Functional/MediaSettingsTest.php create mode 100644 core/modules/media/tests/src/Functional/ProviderRepositoryTest.php create mode 100644 core/modules/media/tests/src/Functional/ResourceFetcherTest.php create mode 100644 core/modules/media/tests/src/Functional/UrlResolverTest.php create mode 100644 core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php create mode 100644 core/modules/media/tests/src/Kernel/OEmbedIframeControllerTest.php create mode 100644 core/modules/media/tests/src/Traits/OEmbedTestTrait.php create mode 100644 core/modules/media/tests/src/Unit/IFrameUrlHelperTest.php create mode 100644 core/themes/stable/templates/content/media-oembed-iframe.html.twig diff --git a/core/modules/media/config/install/media.settings.yml b/core/modules/media/config/install/media.settings.yml index 853e575c6717..5ad89e5e565e 100644 --- a/core/modules/media/config/install/media.settings.yml +++ b/core/modules/media/config/install/media.settings.yml @@ -1 +1,3 @@ icon_base_uri: 'public://media-icons/generic' +iframe_domain: '' +oembed_providers_url: 'https://oembed.com/providers.json' diff --git a/core/modules/media/config/schema/media.schema.yml b/core/modules/media/config/schema/media.schema.yml index b9156b23f86c..f78793b094a4 100644 --- a/core/modules/media/config/schema/media.schema.yml +++ b/core/modules/media/config/schema/media.schema.yml @@ -5,6 +5,12 @@ media.settings: icon_base_uri: type: string label: 'Full URI to a folder where the media icons will be installed' + iframe_domain: + type: uri + label: 'Domain from which to serve oEmbed content in an iframe' + oembed_providers_url: + type: uri + label: 'The URL of the oEmbed providers database in JSON format' media.type.*: type: config_entity @@ -40,6 +46,21 @@ field.formatter.settings.media_thumbnail: type: field.formatter.settings.image label: 'Media thumbnail field display format settings' +field.formatter.settings.oembed: + type: mapping + label: 'oEmbed display format settings' + mapping: + max_width: + type: integer + label: 'Maximum width' + max_height: + type: integer + label: 'Maximum height' + +field.widget.settings.oembed_textfield: + type: field.widget.settings.string_textfield + label: 'oEmbed widget format settings' + media.source.*: type: mapping label: 'Media source settings' @@ -60,6 +81,20 @@ media.source.video_file: type: media.source.field_aware label: '"Video" media source configuration' +media.source.oembed:*: + type: media.source.field_aware + label: 'oEmbed media source configuration' + mapping: + thumbnails_directory: + type: uri + label: 'URI of thumbnail storage directory' + providers: + type: sequence + label: 'Allowed oEmbed providers' + sequence: + type: string + label: 'Provider name' + media.source.field_aware: type: mapping mapping: diff --git a/core/modules/media/media.api.php b/core/modules/media/media.api.php index 8de1c645754b..93244f58a8a1 100644 --- a/core/modules/media/media.api.php +++ b/core/modules/media/media.api.php @@ -20,6 +20,23 @@ function hook_media_source_info_alter(array &$sources) { $sources['youtube']['label'] = t('Youtube rocks!'); } +/** + * Alters an oEmbed resource URL before it is fetched. + * + * @param array $parsed_url + * A parsed URL, as returned by \Drupal\Component\Utility\UrlHelper::parse(). + * @param \Drupal\media\OEmbed\Provider $provider + * The oEmbed provider for the resource. + * + * @see \Drupal\media\OEmbed\UrlResolverInterface::getResourceUrl() + */ +function hook_oembed_resource_url_alter(array &$parsed_url, \Drupal\media\OEmbed\Provider $provider) { + // Always serve YouTube videos from youtube-nocookie.com. + if ($provider->getName() === 'YouTube') { + $parsed_url['path'] = str_replace('://youtube.com/', '://youtube-nocookie.com/', $parsed_url['path']); + } +} + /** * @} End of "addtogroup hooks". */ diff --git a/core/modules/media/media.info.yml b/core/modules/media/media.info.yml index d7a96484c81c..c5b85f502810 100644 --- a/core/modules/media/media.info.yml +++ b/core/modules/media/media.info.yml @@ -8,3 +8,4 @@ dependencies: - drupal:file - drupal:image - drupal:user +configure: media.settings diff --git a/core/modules/media/media.install b/core/modules/media/media.install index 90f9d99cd4da..d3386ea35a77 100644 --- a/core/modules/media/media.install +++ b/core/modules/media/media.install @@ -5,6 +5,9 @@ * Install, uninstall and update hooks for Media module. */ +use Drupal\Core\Url; +use Drupal\media\MediaTypeInterface; +use Drupal\media\Plugin\media\Source\OEmbedInterface; use Drupal\user\RoleInterface; use Drupal\user\Entity\Role; @@ -75,6 +78,36 @@ function media_requirements($phase) { } } } + elseif ($phase === 'runtime') { + // Check that oEmbed content is served in an iframe on a different domain, + // and complain if it isn't. + $domain = \Drupal::config('media.settings')->get('iframe_domain'); + + if (!\Drupal::service('media.oembed.iframe_url_helper')->isSecure($domain)) { + // Find all media types which use a source plugin that implements + // OEmbedInterface. + $media_types = \Drupal::entityTypeManager() + ->getStorage('media_type') + ->loadMultiple(); + + $oembed_types = array_filter($media_types, function (MediaTypeInterface $media_type) { + return $media_type->getSource() instanceof OEmbedInterface; + }); + + if ($oembed_types) { + // @todo Potentially allow site administrators to suppress this warning + // permanently. See https://www.drupal.org/project/drupal/issues/2962753 + // for more information. + $requirements['media_insecure_iframe'] = [ + 'title' => t('Media'), + 'description' => t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. You can specify a different domain for serving oEmbed content here.', [ + ':url' => Url::fromRoute('media.settings')->setAbsolute()->toString(), + ]), + 'severity' => REQUIREMENT_WARNING, + ]; + } + } + } return $requirements; } @@ -120,3 +153,13 @@ function media_update_8500() { $role->save(); } } + +/** + * Updates media.settings to support OEmbed. + */ +function media_update_8600() { + \Drupal::configFactory()->getEditable('media.settings') + ->set('iframe_domain', '') + ->set('oembed_providers_url', 'https://oembed.com/providers.json') + ->save(TRUE); +} diff --git a/core/modules/media/media.links.menu.yml b/core/modules/media/media.links.menu.yml index e50c41217ba8..1bf5fff37d18 100644 --- a/core/modules/media/media.links.menu.yml +++ b/core/modules/media/media.links.menu.yml @@ -3,3 +3,9 @@ entity.media_type.collection: parent: system.admin_structure description: 'Manage media types.' route_name: entity.media_type.collection + +media.settings: + title: 'Media settings' + parent: system.admin_config_media + description: 'Manage media settings.' + route_name: media.settings diff --git a/core/modules/media/media.module b/core/modules/media/media.module index 5079f0fa9a86..20d12b856cc5 100644 --- a/core/modules/media/media.module +++ b/core/modules/media/media.module @@ -5,6 +5,7 @@ * Provides media items. */ +use Drupal\Component\Plugin\DerivativeInspectionInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Form\FormStateInterface; @@ -15,6 +16,7 @@ use Drupal\Core\Session\AccountInterface; use Drupal\Core\Template\Attribute; use Drupal\Core\Url; use Drupal\field\FieldConfigInterface; +use Drupal\media\Plugin\media\Source\OEmbedInterface; /** * Implements hook_help(). @@ -72,6 +74,11 @@ function media_theme() { 'render element' => 'element', 'base hook' => 'field_multiple_value_form', ], + 'media_oembed_iframe' => [ + 'variables' => [ + 'media' => NULL, + ], + ], ]; } @@ -92,6 +99,7 @@ function media_entity_access(EntityInterface $entity, $operation, AccountInterfa */ function media_theme_suggestions_media(array $variables) { $suggestions = []; + /** @var \Drupal\media\MediaInterface $media */ $media = $variables['elements']['#media']; $sanitized_view_mode = strtr($variables['elements']['#view_mode'], '.', '_'); @@ -99,6 +107,31 @@ function media_theme_suggestions_media(array $variables) { $suggestions[] = 'media__' . $media->bundle(); $suggestions[] = 'media__' . $media->bundle() . '__' . $sanitized_view_mode; + // Add suggestions based on the source plugin ID. + $source = $media->getSource(); + if ($source instanceof DerivativeInspectionInterface) { + $source_id = $source->getBaseId(); + $derivative_id = $source->getDerivativeId(); + if ($derivative_id) { + $source_id .= '__derivative_' . $derivative_id; + } + } + else { + $source_id = $source->getPluginId(); + } + $suggestions[] = "media__source_$source_id"; + + // If the source plugin uses oEmbed, add a suggestion based on the provider + // name, if available. + if ($source instanceof OEmbedInterface) { + $provider_id = $source->getMetadata($media, 'provider_name'); + if ($provider_id) { + $provider_id = \Drupal::transliteration()->transliterate($provider_id); + $provider_id = preg_replace('/[^a-z0-9_]+/', '_', mb_strtolower($provider_id)); + $suggestions[] = end($suggestions) . "__provider_$provider_id"; + } + } + return $suggestions; } diff --git a/core/modules/media/media.routing.yml b/core/modules/media/media.routing.yml index ea0858d46298..a9b634c89a24 100644 --- a/core/modules/media/media.routing.yml +++ b/core/modules/media/media.routing.yml @@ -24,3 +24,18 @@ entity.media.revision: requirements: _access_media_revision: 'view' media: \d+ + +media.oembed_iframe: + path: '/media/oembed' + defaults: + _controller: '\Drupal\media\Controller\OEmbedIframeController::render' + requirements: + _permission: 'view media' + +media.settings: + path: '/admin/config/media/media-settings' + defaults: + _form: '\Drupal\media\Form\MediaSettingsForm' + _title: 'Media settings' + requirements: + _permission: 'administer media' diff --git a/core/modules/media/media.services.yml b/core/modules/media/media.services.yml index f22f90a12464..8ff2305a35af 100644 --- a/core/modules/media/media.services.yml +++ b/core/modules/media/media.services.yml @@ -2,9 +2,20 @@ services: plugin.manager.media.source: class: Drupal\media\MediaSourceManager parent: default_plugin_manager - access_check.media.revision: class: Drupal\media\Access\MediaRevisionAccessCheck arguments: ['@entity_type.manager'] tags: - { name: access_check, applies_to: _access_media_revision } + media.oembed.url_resolver: + class: Drupal\media\OEmbed\UrlResolver + arguments: ['@media.oembed.provider_repository', '@media.oembed.resource_fetcher', '@http_client', '@module_handler', '@cache.default'] + media.oembed.provider_repository: + class: Drupal\media\OEmbed\ProviderRepository + arguments: ['@http_client', '@config.factory', '@datetime.time', '@cache.default'] + media.oembed.resource_fetcher: + class: Drupal\media\OEmbed\ResourceFetcher + arguments: ['@http_client', '@media.oembed.provider_repository', '@cache.default'] + media.oembed.iframe_url_helper: + class: Drupal\media\IFrameUrlHelper + arguments: ['@router.request_context', '@private_key'] diff --git a/core/modules/media/src/Controller/OEmbedIframeController.php b/core/modules/media/src/Controller/OEmbedIframeController.php new file mode 100644 index 000000000000..0e45d72f0434 --- /dev/null +++ b/core/modules/media/src/Controller/OEmbedIframeController.php @@ -0,0 +1,174 @@ +resourceFetcher = $resource_fetcher; + $this->urlResolver = $url_resolver; + $this->renderer = $renderer; + $this->logger = $logger; + $this->iFrameUrlHelper = $iframe_url_helper; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('media.oembed.resource_fetcher'), + $container->get('media.oembed.url_resolver'), + $container->get('renderer'), + $container->get('logger.factory')->get('media'), + $container->get('media.oembed.iframe_url_helper') + ); + } + + /** + * Renders an oEmbed resource. + * + * @param \Symfony\Component\HttpFoundation\Request $request + * The request object. + * + * @return \Symfony\Component\HttpFoundation\Response + * The response object. + * + * @throws \Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException + * Will be thrown if the 'hash' parameter does not match the expected hash + * of the 'url' parameter. + */ + public function render(Request $request) { + $url = $request->query->get('url'); + $max_width = $request->query->getInt('max_width', NULL); + $max_height = $request->query->getInt('max_height', NULL); + + // Hash the URL and max dimensions, and ensure it is equal to the hash + // parameter passed in the query string. + $hash = $this->iFrameUrlHelper->getHash($url, $max_width, $max_height); + if (!Crypt::hashEquals($hash, $request->query->get('hash', ''))) { + throw new AccessDeniedHttpException('This resource is not available'); + } + + // Return a response instead of a render array so that the frame content + // will not have all the blocks and page elements normally rendered by + // Drupal. + $response = new CacheableResponse(); + $response->addCacheableDependency(Url::createFromRequest($request)); + + try { + $resource_url = $this->urlResolver->getResourceUrl($url, $max_width, $max_height); + $resource = $this->resourceFetcher->fetchResource($resource_url); + + // Render the content in a new render context so that the cacheability + // metadata of the rendered HTML will be captured correctly. + $content = $this->renderer->executeInRenderContext(new RenderContext(), function () use ($resource) { + $element = [ + '#theme' => 'media_oembed_iframe', + // Even though the resource HTML is untrusted, IFrameMarkup::create() + // will create a trusted string. The only reason this is okay is + // because we are serving it in an iframe, which will mitigate the + // potential dangers of displaying third-party markup. + '#media' => IFrameMarkup::create($resource->getHtml()), + ]; + return $this->renderer->render($element); + }); + + $response->setContent($content)->addCacheableDependency($resource); + } + catch (ResourceException $e) { + // Prevent the response from being cached. + $response->setMaxAge(0); + + // The oEmbed system makes heavy use of exception wrapping, so log the + // entire exception chain to help with troubleshooting. + do { + // @todo Log additional information from ResourceException, to help with + // debugging, in https://www.drupal.org/project/drupal/issues/2972846. + $this->logger->error($e->getMessage()); + $e = $e->getPrevious(); + } while ($e); + } + + return $response; + } + +} diff --git a/core/modules/media/src/Entity/Media.php b/core/modules/media/src/Entity/Media.php index cca42de3878b..b7b274045f3c 100644 --- a/core/modules/media/src/Entity/Media.php +++ b/core/modules/media/src/Entity/Media.php @@ -181,25 +181,7 @@ public function getSource() { * https://www.drupal.org/node/2878119 */ protected function updateThumbnail($from_queue = FALSE) { - $file_storage = \Drupal::service('entity_type.manager')->getStorage('file'); - $thumbnail_uri = $this->getThumbnailUri($from_queue); - $existing = $file_storage->getQuery() - ->condition('uri', $thumbnail_uri) - ->execute(); - - if ($existing) { - $this->thumbnail->target_id = reset($existing); - } - else { - /** @var \Drupal\file\FileInterface $file */ - $file = $file_storage->create(['uri' => $thumbnail_uri]); - if ($owner = $this->getOwner()) { - $file->setOwner($owner); - } - $file->setPermanent(); - $file->save(); - $this->thumbnail->target_id = $file->id(); - } + $this->thumbnail->target_id = $this->loadThumbnail($this->getThumbnailUri($from_queue))->id(); // Set the thumbnail alt. $media_source = $this->getSource(); @@ -222,6 +204,52 @@ protected function updateThumbnail($from_queue = FALSE) { return $this; } + /** + * Loads the file entity for the thumbnail. + * + * If the file entity does not exist, it will be created. + * + * @param string $thumbnail_uri + * (optional) The URI of the thumbnail, used to load or create the file + * entity. If omitted, the default thumbnail URI will be used. + * + * @return \Drupal\file\FileInterface + * The thumbnail file entity. + */ + protected function loadThumbnail($thumbnail_uri = NULL) { + $values = [ + 'uri' => $thumbnail_uri ?: $this->getDefaultThumbnailUri(), + ]; + + $file_storage = $this->entityTypeManager()->getStorage('file'); + + $existing = $file_storage->loadByProperties($values); + if ($existing) { + $file = reset($existing); + } + else { + /** @var \Drupal\file\FileInterface $file */ + $file = $file_storage->create($values); + if ($owner = $this->getOwner()) { + $file->setOwner($owner); + } + $file->setPermanent(); + $file->save(); + } + return $file; + } + + /** + * Returns the URI of the default thumbnail. + * + * @return string + * The default thumbnail URI. + */ + protected function getDefaultThumbnailUri() { + $default_thumbnail_filename = $this->getSource()->getPluginDefinition()['default_thumbnail_filename']; + return \Drupal::config('media.settings')->get('icon_base_uri') . '/' . $default_thumbnail_filename; + } + /** * Updates the queued thumbnail for the media item. * @@ -257,17 +285,14 @@ public function updateQueuedThumbnail() { protected function getThumbnailUri($from_queue) { $thumbnails_queued = $this->bundle->entity->thumbnailDownloadsAreQueued(); if ($thumbnails_queued && $this->isNew()) { - $default_thumbnail_filename = $this->getSource()->getPluginDefinition()['default_thumbnail_filename']; - $thumbnail_uri = \Drupal::service('config.factory')->get('media.settings')->get('icon_base_uri') . '/' . $default_thumbnail_filename; + return $this->getDefaultThumbnailUri(); } elseif ($thumbnails_queued && !$from_queue) { - $thumbnail_uri = $this->get('thumbnail')->entity->getFileUri(); - } - else { - $thumbnail_uri = $this->getSource()->getMetadata($this, $this->getSource()->getPluginDefinition()['thumbnail_uri_metadata_attribute']); + return $this->get('thumbnail')->entity->getFileUri(); } - return $thumbnail_uri; + $source = $this->getSource(); + return $source->getMetadata($this, $source->getPluginDefinition()['thumbnail_uri_metadata_attribute']); } /** @@ -305,30 +330,9 @@ protected function shouldUpdateThumbnail($is_new = FALSE) { public function preSave(EntityStorageInterface $storage) { parent::preSave($storage); - $media_source = $this->getSource(); - foreach ($this->translations as $langcode => $data) { - if ($this->hasTranslation($langcode)) { - $translation = $this->getTranslation($langcode); - // Try to set fields provided by the media source and mapped in - // media type config. - foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) { - // Only save value in entity field if empty. Do not overwrite existing - // data. - if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) { - $translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name)); - } - } - - // Try to set a default name for this media item if no name is provided. - if ($translation->get('name')->isEmpty()) { - $translation->setName($translation->getName()); - } - - // Set thumbnail. - if ($translation->shouldUpdateThumbnail()) { - $translation->updateThumbnail(); - } - } + // If no thumbnail has been explicitly set, use the default thumbnail. + if ($this->get('thumbnail')->isEmpty()) { + $this->thumbnail->target_id = $this->loadThumbnail()->id(); } } @@ -369,6 +373,55 @@ public function preSaveRevision(EntityStorageInterface $storage, \stdClass $reco } } + /** + * {@inheritdoc} + */ + public function save() { + // @todo If the source plugin talks to a remote API (e.g. oEmbed), this code + // might be performing a fair number of HTTP requests. This is dangerously + // brittle and should probably be handled by a queue, to avoid doing HTTP + // operations during entity save. As it is, doing this before calling + // parent::save() is a quick-fix to avoid doing HTTP requests in the middle + // of a database transaction (which begins once we call parent::save()). See + // https://www.drupal.org/project/drupal/issues/2976875 for more. + + // In order for metadata to be mapped correctly, $this->original must be + // set. However, that is only set once parent::save() is called, so work + // around that by setting it here. + if (!isset($this->original) && $id = $this->id()) { + $this->original = $this->entityTypeManager() + ->getStorage('media') + ->loadUnchanged($id); + } + + $media_source = $this->getSource(); + foreach ($this->translations as $langcode => $data) { + if ($this->hasTranslation($langcode)) { + $translation = $this->getTranslation($langcode); + // Try to set fields provided by the media source and mapped in + // media type config. + foreach ($translation->bundle->entity->getFieldMap() as $metadata_attribute_name => $entity_field_name) { + // Only save value in entity field if empty. Do not overwrite existing + // data. + if ($translation->hasField($entity_field_name) && ($translation->get($entity_field_name)->isEmpty() || $translation->hasSourceFieldChanged())) { + $translation->set($entity_field_name, $media_source->getMetadata($translation, $metadata_attribute_name)); + } + } + + // Try to set a default name for this media item if no name is provided. + if ($translation->get('name')->isEmpty()) { + $translation->setName($translation->getName()); + } + + // Set thumbnail. + if ($translation->shouldUpdateThumbnail($this->isNew())) { + $translation->updateThumbnail(); + } + } + } + return parent::save(); + } + /** * {@inheritdoc} */ diff --git a/core/modules/media/src/Form/MediaSettingsForm.php b/core/modules/media/src/Form/MediaSettingsForm.php new file mode 100644 index 000000000000..d165fca56360 --- /dev/null +++ b/core/modules/media/src/Form/MediaSettingsForm.php @@ -0,0 +1,108 @@ +iFrameUrlHelper = $iframe_url_helper; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('config.factory'), + $container->get('media.oembed.iframe_url_helper') + ); + } + + /** + * {@inheritdoc} + */ + public function getFormId() { + return 'media_settings_form'; + } + + /** + * {@inheritdoc} + */ + protected function getEditableConfigNames() { + return ['media.settings']; + } + + /** + * {@inheritdoc} + */ + public function buildForm(array $form, FormStateInterface $form_state) { + $domain = $this->config('media.settings')->get('iframe_domain'); + + if (!$this->iFrameUrlHelper->isSecure($domain)) { + $message = $this->t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. Take a look here for more information.'); + $this->messenger()->addWarning($message); + } + + $description = '

' . $this->t('Displaying media assets from third-party services, such as YouTube or Twitter, can be risky. This is because many of these services return arbitrary HTML to represent those assets, and that HTML may contain executable JavaScript code. If handled improperly, this can increase the risk of your site being compromised.') . '

'; + $description .= '

' . $this->t('In order to mitigate the risks, third-party assets are displayed in an iFrame, which effectively sandboxes any executable code running inside it. For even more security, the iFrame can be served from an alternate domain (that also points to your Drupal site), which you can configure on this page. This helps safeguard cookies and other sensitive information.') . '

'; + + $form['security'] = [ + '#type' => 'details', + '#title' => $this->t('Security'), + '#description' => $description, + '#open' => TRUE, + ]; + // @todo Figure out how and if we should validate that this domain actually + // points back to Drupal. + // See https://www.drupal.org/project/drupal/issues/2965979 for more info. + $form['security']['iframe_domain'] = [ + '#type' => 'url', + '#title' => $this->t('iFrame domain'), + '#size' => 40, + '#maxlength' => 255, + '#default_value' => $domain, + '#description' => $this->t('Enter a different domain from which to serve oEmbed content, including the http:// or https:// prefix. This domain needs to point back to this site, or existing oEmbed content may not display correctly, or at all.'), + ]; + + return parent::buildForm($form, $form_state); + } + + /** + * {@inheritdoc} + */ + public function submitForm(array &$form, FormStateInterface $form_state) { + $this->config('media.settings') + ->set('iframe_domain', $form_state->getValue('iframe_domain')) + ->save(); + + parent::submitForm($form, $form_state); + } + +} diff --git a/core/modules/media/src/IFrameMarkup.php b/core/modules/media/src/IFrameMarkup.php new file mode 100644 index 000000000000..b6efbbb12360 --- /dev/null +++ b/core/modules/media/src/IFrameMarkup.php @@ -0,0 +1,24 @@ +requestContext = $request_context; + $this->privateKey = $private_key; + } + + /** + * Hashes an oEmbed resource URL. + * + * @param string $url + * The resource URL. + * @param int $max_width + * (optional) The maximum width of the resource. + * @param int $max_height + * (optional) The maximum height of the resource. + * + * @return string + * The hashed URL. + */ + public function getHash($url, $max_width = NULL, $max_height = NULL) { + return Crypt::hmacBase64("$url:$max_width:$max_height", $this->privateKey->get() . Settings::getHashSalt()); + } + + /** + * Checks if an oEmbed URL can be securely displayed in an frame. + * + * @param string $url + * The URL to check. + * + * @return bool + * TRUE if the URL is considered secure, otherwise FALSE. + */ + public function isSecure($url) { + if (!$url) { + return FALSE; + } + $url_host = parse_url($url, PHP_URL_HOST); + $system_host = parse_url($this->requestContext->getCompleteBaseUrl(), PHP_URL_HOST); + + // The URL is secure if its domain is not the same as the domain of the base + // URL of the current request. + return $url_host && $system_host && $url_host !== $system_host; + } + +} diff --git a/core/modules/media/src/MediaSourceBase.php b/core/modules/media/src/MediaSourceBase.php index 1edc8584508e..c01b9946ea0f 100644 --- a/core/modules/media/src/MediaSourceBase.php +++ b/core/modules/media/src/MediaSourceBase.php @@ -301,7 +301,9 @@ public function createSourceField(MediaTypeInterface $type) { * returned. Otherwise, a new, unused one is generated. */ protected function getSourceFieldName() { - $base_id = 'field_media_' . $this->getPluginId(); + // Some media sources are using a deriver, so their plugin IDs may contain + // a separator (usually ':') which is not allowed in field names. + $base_id = 'field_media_' . str_replace(static::DERIVATIVE_SEPARATOR, '_', $this->getPluginId()); $tries = 0; $storage = $this->entityTypeManager->getStorage('field_storage_config'); diff --git a/core/modules/media/src/OEmbed/Endpoint.php b/core/modules/media/src/OEmbed/Endpoint.php new file mode 100644 index 000000000000..38d265d5bd78 --- /dev/null +++ b/core/modules/media/src/OEmbed/Endpoint.php @@ -0,0 +1,176 @@ +provider = $provider; + $this->schemes = array_map('mb_strtolower', $schemes); + + $this->formats = $formats = array_map('mb_strtolower', $formats); + // Assert that only the supported formats are present. + assert(array_diff($formats, ['json', 'xml']) == []); + + // Use the first provided format to build the endpoint URL. If no formats + // are provided, default to JSON. + $this->url = str_replace('{format}', reset($this->formats) ?: 'json', $url); + + if (!UrlHelper::isValid($this->url, TRUE) || !UrlHelper::isExternal($this->url)) { + throw new \InvalidArgumentException('oEmbed endpoint must have a valid external URL'); + } + + $this->supportsDiscovery = (bool) $supports_discovery; + } + + /** + * Returns the endpoint URL. + * + * The URL will be built with the first available format. If the endpoint + * does not provide any formats, JSON will be used. + * + * @return string + * The endpoint URL. + */ + public function getUrl() { + return $this->url; + } + + /** + * Returns the provider this endpoint belongs to. + * + * @return \Drupal\media\OEmbed\Provider + * The provider object. + */ + public function getProvider() { + return $this->provider; + } + + /** + * Returns list of URL schemes supported by the provider. + * + * @return string[] + * List of schemes. + */ + public function getSchemes() { + return $this->schemes; + } + + /** + * Returns list of supported formats. + * + * @return string[] + * List of formats. + */ + public function getFormats() { + return $this->formats; + } + + /** + * Returns whether the provider supports oEmbed discovery. + * + * @return bool + * Returns TRUE if the provides discovery, otherwise FALSE. + */ + public function supportsDiscovery() { + return $this->supportsDiscovery; + } + + /** + * Tries to match a URL against the endpoint schemes. + * + * @param string $url + * Media item URL. + * + * @return bool + * TRUE if the URL matches against the endpoint schemes, otherwise FALSE. + */ + public function matchUrl($url) { + foreach ($this->getSchemes() as $scheme) { + // Convert scheme into a valid regular expression. + $regexp = str_replace(['.', '*'], ['\.', '.*'], $scheme); + if (preg_match("|^$regexp$|", $url)) { + return TRUE; + } + } + return FALSE; + } + + /** + * Builds and returns the endpoint URL. + * + * @param string $url + * The canonical media URL. + * + * @return string + * URL of the oEmbed endpoint. + */ + public function buildResourceUrl($url) { + $query = ['url' => $url]; + return $this->getUrl() . '?' . UrlHelper::buildQuery($query); + } + +} diff --git a/core/modules/media/src/OEmbed/Provider.php b/core/modules/media/src/OEmbed/Provider.php new file mode 100644 index 000000000000..954dac1c9fd3 --- /dev/null +++ b/core/modules/media/src/OEmbed/Provider.php @@ -0,0 +1,100 @@ +name = $name; + $this->url = $url; + + try { + foreach ($endpoints as $endpoint) { + $endpoint += ['formats' => [], 'schemes' => [], 'discovery' => FALSE]; + $this->endpoints[] = new Endpoint($endpoint['url'], $this, $endpoint['schemes'], $endpoint['formats'], $endpoint['discovery']); + } + } + catch (\InvalidArgumentException $e) { + // Just skip all the invalid endpoints. + // @todo Log the exception message to help with debugging in + // https://www.drupal.org/project/drupal/issues/2972846. + } + + if (empty($this->endpoints)) { + throw new ProviderException('Provider @name does not define any valid endpoints.', $this); + } + } + + /** + * Returns the provider name. + * + * @return string + * Name of the provider. + */ + public function getName() { + return $this->name; + } + + /** + * Returns the provider URL. + * + * @return string + * URL of the provider. + */ + public function getUrl() { + return $this->url; + } + + /** + * Returns the provider endpoints. + * + * @return \Drupal\media\OEmbed\Endpoint[] + * List of endpoints this provider exposes. + */ + public function getEndpoints() { + return $this->endpoints; + } + +} diff --git a/core/modules/media/src/OEmbed/ProviderException.php b/core/modules/media/src/OEmbed/ProviderException.php new file mode 100644 index 000000000000..259c939ed115 --- /dev/null +++ b/core/modules/media/src/OEmbed/ProviderException.php @@ -0,0 +1,40 @@ +' if not. + * @param \Drupal\media\OEmbed\Provider $provider + * (optional) The provider information. + * @param \Exception $previous + * (optional) The previous exception, if any. + */ + public function __construct($message, Provider $provider = NULL, \Exception $previous = NULL) { + $this->provider = $provider; + $message = str_replace('@name', $provider ? $provider->getName() : '', $message); + parent::__construct($message, 0, $previous); + } + +} diff --git a/core/modules/media/src/OEmbed/ProviderRepository.php b/core/modules/media/src/OEmbed/ProviderRepository.php new file mode 100644 index 000000000000..dada7fb2553e --- /dev/null +++ b/core/modules/media/src/OEmbed/ProviderRepository.php @@ -0,0 +1,122 @@ +httpClient = $http_client; + $this->providersUrl = $config_factory->get('media.settings')->get('oembed_providers_url'); + $this->time = $time; + $this->cacheBackend = $cache_backend; + $this->maxAge = (int) $max_age; + } + + /** + * {@inheritdoc} + */ + public function getAll() { + $cache_id = 'media:oembed_providers'; + + $cached = $this->cacheGet($cache_id); + if ($cached) { + return $cached->data; + } + + try { + $response = $this->httpClient->request('GET', $this->providersUrl); + } + catch (RequestException $e) { + throw new ProviderException("Could not retrieve the oEmbed provider database from $this->providersUrl", NULL, $e); + } + + $providers = Json::decode((string) $response->getBody()); + + if (!is_array($providers) || empty($providers)) { + throw new ProviderException('Remote oEmbed providers database returned invalid or empty list.'); + } + + $keyed_providers = []; + foreach ($providers as $provider) { + try { + $name = (string) $provider['provider_name']; + $keyed_providers[$name] = new Provider($provider['provider_name'], $provider['provider_url'], $provider['endpoints']); + } + catch (ProviderException $e) { + // Just skip all the invalid providers. + // @todo Log the exception message to help with debugging. + } + } + + $this->cacheSet($cache_id, $keyed_providers, $this->time->getCurrentTime() + $this->maxAge); + return $keyed_providers; + } + + /** + * {@inheritdoc} + */ + public function get($provider_name) { + $providers = $this->getAll(); + + if (!isset($providers[$provider_name])) { + throw new \InvalidArgumentException("Unknown provider '$provider_name'"); + } + return $providers[$provider_name]; + } + +} diff --git a/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php b/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php new file mode 100644 index 000000000000..b6e63afacba8 --- /dev/null +++ b/core/modules/media/src/OEmbed/ProviderRepositoryInterface.php @@ -0,0 +1,40 @@ +provider = $provider; + $this->title = $title; + $this->authorName = $author_name; + $this->authorUrl = $author_url; + + if (isset($cache_age) && is_numeric($cache_age)) { + // If the cache age is too big, it can overflow the 'expire' column of + // database cache backends, causing SQL exceptions. To prevent that, + // arbitrarily limit the cache age to 5 years. That should be enough. + $this->cacheMaxAge = Cache::mergeMaxAges((int) $cache_age, 157680000); + } + + if ($thumbnail_url) { + $this->thumbnailUrl = $thumbnail_url; + $this->setThumbnailDimensions($thumbnail_width, $thumbnail_height); + } + } + + /** + * Creates a link resource. + * + * @param string $url + * (optional) The URL of the resource. + * @param \Drupal\media\OEmbed\Provider $provider + * (optional) The resource provider. + * @param string $title + * (optional) A text title, describing the resource. + * @param string $author_name + * (optional) The name of the author/owner of the resource. + * @param string $author_url + * (optional) A URL for the author/owner of the resource. + * @param int $cache_age + * (optional) The suggested cache lifetime for this resource, in seconds. + * @param string $thumbnail_url + * (optional) A URL to a thumbnail image representing the resource. If this + * parameter is present, $thumbnail_width and $thumbnail_height must also be + * present. + * @param int $thumbnail_width + * (optional) The width of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_height must also be present. + * @param int $thumbnail_height + * (optional) The height of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_width must also be present. + * + * @return static + */ + public static function link($url = NULL, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) { + $resource = new static($provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height); + $resource->type = self::TYPE_LINK; + $resource->url = $url; + + return $resource; + } + + /** + * Creates a photo resource. + * + * @param string $url + * The URL of the photo. + * @param int $width + * The width of the photo, in pixels. + * @param int $height + * The height of the photo, in pixels. + * @param \Drupal\media\OEmbed\Provider $provider + * (optional) The resource provider. + * @param string $title + * (optional) A text title, describing the resource. + * @param string $author_name + * (optional) The name of the author/owner of the resource. + * @param string $author_url + * (optional) A URL for the author/owner of the resource. + * @param int $cache_age + * (optional) The suggested cache lifetime for this resource, in seconds. + * @param string $thumbnail_url + * (optional) A URL to a thumbnail image representing the resource. If this + * parameter is present, $thumbnail_width and $thumbnail_height must also be + * present. + * @param int $thumbnail_width + * (optional) The width of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_height must also be present. + * @param int $thumbnail_height + * (optional) The height of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_width must also be present. + * + * @return static + */ + public static function photo($url, $width, $height, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) { + if (empty($url)) { + throw new \InvalidArgumentException('Photo resources must provide a URL.'); + } + + $resource = static::link($url, $provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height); + $resource->type = self::TYPE_PHOTO; + $resource->setDimensions($width, $height); + + return $resource; + } + + /** + * Creates a rich resource. + * + * @param string $html + * The HTML representation of the resource. + * @param int $width + * The width of the resource, in pixels. + * @param int $height + * The height of the resource, in pixels. + * @param \Drupal\media\OEmbed\Provider $provider + * (optional) The resource provider. + * @param string $title + * (optional) A text title, describing the resource. + * @param string $author_name + * (optional) The name of the author/owner of the resource. + * @param string $author_url + * (optional) A URL for the author/owner of the resource. + * @param int $cache_age + * (optional) The suggested cache lifetime for this resource, in seconds. + * @param string $thumbnail_url + * (optional) A URL to a thumbnail image representing the resource. If this + * parameter is present, $thumbnail_width and $thumbnail_height must also be + * present. + * @param int $thumbnail_width + * (optional) The width of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_height must also be present. + * @param int $thumbnail_height + * (optional) The height of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_width must also be present. + * + * @return static + */ + public static function rich($html, $width, $height, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) { + if (empty($html)) { + throw new \InvalidArgumentException('The resource must provide an HTML representation.'); + } + + $resource = new static($provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height); + $resource->type = self::TYPE_RICH; + $resource->html = $html; + $resource->setDimensions($width, $height); + + return $resource; + } + + /** + * Creates a video resource. + * + * @param string $html + * The HTML required to display the video. + * @param int $width + * The width of the video, in pixels. + * @param int $height + * The height of the video, in pixels. + * @param \Drupal\media\OEmbed\Provider $provider + * (optional) The resource provider. + * @param string $title + * (optional) A text title, describing the resource. + * @param string $author_name + * (optional) The name of the author/owner of the resource. + * @param string $author_url + * (optional) A URL for the author/owner of the resource. + * @param int $cache_age + * (optional) The suggested cache lifetime for this resource, in seconds. + * @param string $thumbnail_url + * (optional) A URL to a thumbnail image representing the resource. If this + * parameter is present, $thumbnail_width and $thumbnail_height must also be + * present. + * @param int $thumbnail_width + * (optional) The width of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_height must also be present. + * @param int $thumbnail_height + * (optional) The height of the thumbnail, in pixels. If this parameter is + * present, $thumbnail_url and $thumbnail_width must also be present. + * + * @return static + */ + public static function video($html, $width, $height, Provider $provider = NULL, $title = NULL, $author_name = NULL, $author_url = NULL, $cache_age = NULL, $thumbnail_url = NULL, $thumbnail_width = NULL, $thumbnail_height = NULL) { + $resource = static::rich($html, $width, $height, $provider, $title, $author_name, $author_url, $cache_age, $thumbnail_url, $thumbnail_width, $thumbnail_height); + $resource->type = self::TYPE_VIDEO; + + return $resource; + } + + /** + * Returns the resource type. + * + * @return string + * The resource type. Will be one of the self::TYPE_* constants. + */ + public function getType() { + return $this->type; + } + + /** + * Returns the title of the resource. + * + * @return string|null + * The title of the resource, if known. + */ + public function getTitle() { + return $this->title; + } + + /** + * Returns the name of the resource author. + * + * @return string|null + * The name of the resource author, if known. + */ + public function getAuthorName() { + return $this->authorName; + } + + /** + * Returns the URL of the resource author. + * + * @return \Drupal\Core\Url|null + * The absolute URL of the resource author, or NULL if none is provided. + */ + public function getAuthorUrl() { + return $this->authorUrl ? Url::fromUri($this->authorUrl)->setAbsolute() : NULL; + } + + /** + * Returns the resource provider, if known. + * + * @return \Drupal\media\OEmbed\Provider|null + * The resource provider, or NULL if the provider is not known. + */ + public function getProvider() { + return $this->provider; + } + + /** + * Returns the URL of the resource's thumbnail image. + * + * @return \Drupal\Core\Url|null + * The absolute URL of the thumbnail image, or NULL if there isn't one. + */ + public function getThumbnailUrl() { + return $this->thumbnailUrl ? Url::fromUri($this->thumbnailUrl)->setAbsolute() : NULL; + } + + /** + * Returns the width of the resource's thumbnail image. + * + * @return int|null + * The thumbnail width in pixels, or NULL if there is no thumbnail. + */ + public function getThumbnailWidth() { + return $this->thumbnailWidth; + } + + /** + * Returns the height of the resource's thumbnail image. + * + * @return int|null + * The thumbnail height in pixels, or NULL if there is no thumbnail. + */ + public function getThumbnailHeight() { + return $this->thumbnailHeight; + } + + /** + * Returns the width of the resource. + * + * @return int|null + * The width of the resource in pixels, or NULL if the resource has no + * dimensions + */ + public function getWidth() { + return $this->width; + } + + /** + * Returns the height of the resource. + * + * @return int|null + * The height of the resource in pixels, or NULL if the resource has no + * dimensions. + */ + public function getHeight() { + return $this->height; + } + + /** + * Returns the URL of the resource. Only applies to 'photo' resources. + * + * @return \Drupal\Core\Url|null + * The resource URL, if it has one. + */ + public function getUrl() { + if ($this->url) { + return Url::fromUri($this->url)->setAbsolute(); + } + return NULL; + } + + /** + * Returns the HTML representation of the resource. + * + * Only applies to 'rich' and 'video' resources. + * + * @return string|null + * The HTML representation of the resource, if it has one. + */ + public function getHtml() { + return isset($this->html) ? (string) $this->html : NULL; + } + + /** + * Sets the thumbnail dimensions. + * + * @param int $width + * The width of the resource. + * @param int $height + * The height of the resource. + * + * @throws \InvalidArgumentException + * If either $width or $height are not numbers greater than zero. + */ + protected function setThumbnailDimensions($width, $height) { + $width = (int) $width; + $height = (int) $height; + + if ($width > 0 && $height > 0) { + $this->thumbnailWidth = $width; + $this->thumbnailHeight = $height; + } + else { + throw new \InvalidArgumentException('The thumbnail dimensions must be numbers greater than zero.'); + } + } + + /** + * Sets the dimensions. + * + * @param int $width + * The width of the resource. + * @param int $height + * The height of the resource. + * + * @throws \InvalidArgumentException + * If either $width or $height are not numbers greater than zero. + */ + protected function setDimensions($width, $height) { + $width = (int) $width; + $height = (int) $height; + + if ($width > 0 && $height > 0) { + $this->width = $width; + $this->height = $height; + } + else { + throw new \InvalidArgumentException('The dimensions must be numbers greater than zero.'); + } + } + +} diff --git a/core/modules/media/src/OEmbed/ResourceException.php b/core/modules/media/src/OEmbed/ResourceException.php new file mode 100644 index 000000000000..433867834e54 --- /dev/null +++ b/core/modules/media/src/OEmbed/ResourceException.php @@ -0,0 +1,67 @@ +url = $url; + $this->data = $data; + parent::__construct($message, 0, $previous); + } + + /** + * Gets the URL of the resource which caused the exception. + * + * @return string + * The URL of the resource. + */ + public function getUrl() { + return $this->url; + } + + /** + * Gets the raw resource data, if available. + * + * @return array + * The resource data. + */ + public function getData() { + return $this->data; + } + +} diff --git a/core/modules/media/src/OEmbed/ResourceFetcher.php b/core/modules/media/src/OEmbed/ResourceFetcher.php new file mode 100644 index 000000000000..0c210878feaa --- /dev/null +++ b/core/modules/media/src/OEmbed/ResourceFetcher.php @@ -0,0 +1,197 @@ +httpClient = $http_client; + $this->providers = $providers; + $this->cacheBackend = $cache_backend; + $this->useCaches = isset($cache_backend); + } + + /** + * {@inheritdoc} + */ + public function fetchResource($url) { + $cache_id = "media:oembed_resource:$url"; + + $cached = $this->cacheGet($cache_id); + if ($cached) { + return $this->createResource($cached->data, $url); + } + + try { + $response = $this->httpClient->get($url); + } + catch (RequestException $e) { + throw new ResourceException('Could not retrieve the oEmbed resource.', $url, [], $e); + } + + list($format) = $response->getHeader('Content-Type'); + $content = (string) $response->getBody(); + + if (strstr($format, 'text/xml') || strstr($format, 'application/xml')) { + $encoder = new XmlEncoder(); + $data = $encoder->decode($content, 'xml'); + } + elseif (strstr($format, 'text/javascript') || strstr($format, 'application/json')) { + $data = Json::decode($content); + } + // If the response is neither XML nor JSON, we are in bat country. + else { + throw new ResourceException('The fetched resource did not have a valid Content-Type header.', $url); + } + + $this->cacheSet($cache_id, $data); + + return $this->createResource($data, $url); + } + + /** + * Creates a Resource object from raw resource data. + * + * @param array $data + * The resource data returned by the provider. + * @param string $url + * The URL of the resource. + * + * @return \Drupal\media\OEmbed\Resource + * A value object representing the resource. + * + * @throws \Drupal\media\OEmbed\ResourceException + * If the resource cannot be created. + */ + protected function createResource(array $data, $url) { + $data += [ + 'title' => NULL, + 'author_name' => NULL, + 'author_url' => NULL, + 'provider_name' => NULL, + 'cache_age' => NULL, + 'thumbnail_url' => NULL, + 'thumbnail_width' => NULL, + 'thumbnail_height' => NULL, + 'width' => NULL, + 'height' => NULL, + 'url' => NULL, + 'html' => NULL, + 'version' => NULL, + ]; + + if ($data['version'] !== '1.0') { + throw new ResourceException("Resource version must be '1.0'", $url, $data); + } + + // Prepare the arguments to pass to the factory method. + $provider = $data['provider_name'] ? $this->providers->get($data['provider_name']) : NULL; + + // The Resource object will validate the data we create it with and throw an + // exception if anything looks wrong. For better debugging, catch those + // exceptions and wrap them in a more specific and useful exception. + try { + switch ($data['type']) { + case Resource::TYPE_LINK: + return Resource::link( + $data['url'], + $provider, + $data['title'], + $data['author_name'], + $data['author_url'], + $data['cache_age'], + $data['thumbnail_url'], + $data['thumbnail_width'], + $data['thumbnail_height'] + ); + + case Resource::TYPE_PHOTO: + return Resource::photo( + $data['url'], + $data['width'], + $data['height'], + $provider, + $data['title'], + $data['author_name'], + $data['author_url'], + $data['cache_age'], + $data['thumbnail_url'], + $data['thumbnail_width'], + $data['thumbnail_height'] + ); + + case Resource::TYPE_RICH: + return Resource::rich( + $data['html'], + $data['width'], + $data['height'], + $provider, + $data['title'], + $data['author_name'], + $data['author_url'], + $data['cache_age'], + $data['thumbnail_url'], + $data['thumbnail_width'], + $data['thumbnail_height'] + ); + case Resource::TYPE_VIDEO: + return Resource::video( + $data['html'], + $data['width'], + $data['height'], + $provider, + $data['title'], + $data['author_name'], + $data['author_url'], + $data['cache_age'], + $data['thumbnail_url'], + $data['thumbnail_width'], + $data['thumbnail_height'] + ); + + default: + throw new ResourceException('Unknown resource type: ' . $data['type'], $url, $data); + } + } + catch (\InvalidArgumentException $e) { + throw new ResourceException($e->getMessage(), $url, $data, $e); + } + } + +} diff --git a/core/modules/media/src/OEmbed/ResourceFetcherInterface.php b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php new file mode 100644 index 000000000000..b74fb6e2d9c4 --- /dev/null +++ b/core/modules/media/src/OEmbed/ResourceFetcherInterface.php @@ -0,0 +1,32 @@ +providers = $providers; + $this->resourceFetcher = $resource_fetcher; + $this->httpClient = $http_client; + $this->moduleHandler = $module_handler; + $this->cacheBackend = $cache_backend; + $this->useCaches = isset($cache_backend); + } + + /** + * Runs oEmbed discovery and returns the endpoint URL if successful. + * + * @param string $url + * The resource's URL. + * + * @return string|bool + * URL of the oEmbed endpoint, or FALSE if the discovery was unsuccessful. + * + * @throws \Drupal\media\OEmbed\ResourceException + * If the resource cannot be retrieved. + */ + protected function discoverResourceUrl($url) { + try { + $response = $this->httpClient->get($url); + } + catch (RequestException $e) { + throw new ResourceException('Could not fetch oEmbed resource.', $url, [], $e); + } + + $document = Html::load((string) $response->getBody()); + $xpath = new \DOMXpath($document); + + return $this->findUrl($xpath, 'json') ?: $this->findUrl($xpath, 'xml'); + } + + /** + * Tries to find the oEmbed URL in a DOM. + * + * @param \DOMXPath $xpath + * Page HTML as DOMXPath. + * @param string $format + * Format of oEmbed resource. Possible values are 'json' and 'xml'. + * + * @return bool|string + * A URL to an oEmbed resource or FALSE if not found. + */ + protected function findUrl(\DOMXPath $xpath, $format) { + $result = $xpath->query("//link[@type='application/$format+oembed']"); + return $result->length ? $result->item(0)->getAttribute('href') : FALSE; + } + + /** + * {@inheritdoc} + */ + public function getProviderByUrl($url) { + // Check the URL against every scheme of every endpoint of every provider + // until we find a match. + foreach ($this->providers->getAll() as $provider_name => $provider_info) { + foreach ($provider_info->getEndpoints() as $endpoint) { + if ($endpoint->matchUrl($url)) { + return $provider_info; + } + } + } + + $resource_url = $this->discoverResourceUrl($url); + if ($resource_url) { + return $this->resourceFetcher->fetchResource($resource_url)->getProvider(); + } + + throw new ResourceException('No matching provider found.', $url); + } + + /** + * {@inheritdoc} + */ + public function getResourceUrl($url, $max_width = NULL, $max_height = NULL) { + // Try to get the resource URL from the static cache. + if (isset($this->urlCache[$url])) { + return $this->urlCache[$url]; + } + + // Try to get the resource URL from the persistent cache. + $cache_id = "media:oembed_resource_url:$url:$max_width:$max_height"; + + $cached = $this->cacheGet($cache_id); + if ($cached) { + $this->urlCache[$url] = $cached->data; + return $this->urlCache[$url]; + } + + $provider = $this->getProviderByUrl($url); + $endpoints = $provider->getEndpoints(); + $endpoint = reset($endpoints); + $resource_url = $endpoint->buildResourceUrl($url); + + $parsed_url = UrlHelper::parse($resource_url); + if ($max_width) { + $parsed_url['query']['maxwidth'] = $max_width; + } + if ($max_height) { + $parsed_url['query']['maxheight'] = $max_height; + } + // Let other modules alter the resource URL, because some oEmbed providers + // provide extra parameters in the query string. For example, Instagram also + // supports the 'omitscript' parameter. + $this->moduleHandler->alter('oembed_resource_url', $parsed_url, $provider); + $resource_url = $parsed_url['path'] . '?' . UrlHelper::buildQuery($parsed_url['query']); + + $this->urlCache[$url] = $resource_url; + $this->cacheSet($cache_id, $resource_url); + + return $resource_url; + } + +} diff --git a/core/modules/media/src/OEmbed/UrlResolverInterface.php b/core/modules/media/src/OEmbed/UrlResolverInterface.php new file mode 100644 index 000000000000..b401f6201a52 --- /dev/null +++ b/core/modules/media/src/OEmbed/UrlResolverInterface.php @@ -0,0 +1,45 @@ +messenger = $messenger; + $this->resourceFetcher = $resource_fetcher; + $this->urlResolver = $url_resolver; + $this->logger = $logger_factory->get('media'); + $this->config = $config_factory->get('media.settings'); + $this->iFrameUrlHelper = $iframe_url_helper; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $plugin_id, + $plugin_definition, + $configuration['field_definition'], + $configuration['settings'], + $configuration['label'], + $configuration['view_mode'], + $configuration['third_party_settings'], + $container->get('messenger'), + $container->get('media.oembed.resource_fetcher'), + $container->get('media.oembed.url_resolver'), + $container->get('logger.factory'), + $container->get('config.factory'), + $container->get('media.oembed.iframe_url_helper') + ); + } + + /** + * {@inheritdoc} + */ + public static function defaultSettings() { + return [ + 'max_width' => 0, + 'max_height' => 0, + ] + parent::defaultSettings(); + } + + /** + * {@inheritdoc} + */ + public function viewElements(FieldItemListInterface $items, $langcode) { + $element = []; + $max_width = $this->getSetting('max_width'); + $max_height = $this->getSetting('max_height'); + + foreach ($items as $delta => $item) { + $main_property = $item->getFieldDefinition()->getFieldStorageDefinition()->getMainPropertyName(); + $value = $item->{$main_property}; + + if (empty($value)) { + continue; + } + + try { + $resource_url = $this->urlResolver->getResourceUrl($value, $max_width, $max_height); + $resource = $this->resourceFetcher->fetchResource($resource_url); + } + catch (ResourceException $exception) { + $this->logger->error("Could not retrieve the remote URL (@url).", ['@url' => $value]); + continue; + } + + if ($resource->getType() === Resource::TYPE_LINK) { + $element[$delta] = [ + '#title' => $resource->getTitle(), + '#type' => 'link', + '#url' => Url::fromUri($value), + ]; + } + elseif ($resource->getType() === Resource::TYPE_PHOTO) { + $element[$delta] = [ + '#theme' => 'image', + '#uri' => $resource->getUrl()->toString(), + '#width' => $max_width ?: $resource->getWidth(), + '#height' => $max_height ?: $resource->getHeight(), + ]; + } + else { + $url = Url::fromRoute('media.oembed_iframe', [], [ + 'query' => [ + 'url' => $value, + 'max_width' => $max_width, + 'max_height' => $max_height, + 'hash' => $this->iFrameUrlHelper->getHash($value, $max_width, $max_height), + ], + ]); + + $domain = $this->config->get('iframe_domain'); + if ($domain) { + $url->setOption('base_url', $domain); + } + + // Render videos and rich content in an iframe for security reasons. + // @see: https://oembed.com/#section3 + $element[$delta] = [ + '#type' => 'html_tag', + '#tag' => 'iframe', + '#attributes' => [ + 'src' => $url->toString(), + 'frameborder' => 0, + 'scrolling' => FALSE, + 'allowtransparency' => TRUE, + 'width' => $max_width ?: $resource->getWidth(), + 'height' => $max_height ?: $resource->getHeight(), + ], + ]; + + CacheableMetadata::createFromObject($resource) + ->addCacheTags($this->config->getCacheTags()) + ->applyTo($element[$delta]); + } + } + return $element; + } + + /** + * {@inheritdoc} + */ + public function settingsForm(array $form, FormStateInterface $form_state) { + return parent::settingsForm($form, $form_state) + [ + 'max_width' => [ + '#type' => 'number', + '#title' => $this->t('Maximum width'), + '#default_value' => $this->getSetting('max_width'), + '#size' => 5, + '#maxlength' => 5, + '#field_suffix' => $this->t('pixels'), + '#min' => 0, + ], + 'max_height' => [ + '#type' => 'number', + '#title' => $this->t('Maximum height'), + '#default_value' => $this->getSetting('max_height'), + '#size' => 5, + '#maxlength' => 5, + '#field_suffix' => $this->t('pixels'), + '#min' => 0, + ], + ]; + } + + /** + * {@inheritdoc} + */ + public function settingsSummary() { + $summary = parent::settingsSummary(); + if ($this->getSetting('max_width') && $this->getSetting('max_height')) { + $summary[] = $this->t('Maximum size: %max_width x %max_height pixels', [ + '%max_width' => $this->getSetting('max_width'), + '%max_height' => $this->getSetting('max_height'), + ]); + } + elseif ($this->getSetting('max_width')) { + $summary[] = $this->t('Maximum width: %max_width pixels', [ + '%max_width' => $this->getSetting('max_width'), + ]); + } + elseif ($this->getSetting('max_height')) { + $summary[] = $this->t('Maximum height: %max_height pixels', [ + '%max_height' => $this->getSetting('max_height'), + ]); + } + return $summary; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + if ($field_definition->getTargetEntityTypeId() !== 'media') { + return FALSE; + } + + if (parent::isApplicable($field_definition)) { + $media_type = $field_definition->getTargetBundle(); + + if ($media_type) { + $media_type = MediaType::load($media_type); + return $media_type && $media_type->getSource() instanceof OEmbedInterface; + } + } + return FALSE; + } + +} diff --git a/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php new file mode 100644 index 000000000000..1252265e45b8 --- /dev/null +++ b/core/modules/media/src/Plugin/Field/FieldWidget/OEmbedWidget.php @@ -0,0 +1,64 @@ +getEntity()->getSource(); + $message = $this->t('You can link to media from the following services: @providers', ['@providers' => implode(', ', $source->getProviders())]); + + if (!empty($element['#value']['#description'])) { + $element['value']['#description'] = [ + '#theme' => 'item_list', + '#items' => [$element['value']['#description'], $message], + ]; + } + else { + $element['value']['#description'] = $message; + } + + return $element; + } + + /** + * {@inheritdoc} + */ + public static function isApplicable(FieldDefinitionInterface $field_definition) { + $target_bundle = $field_definition->getTargetBundle(); + + if (!parent::isApplicable($field_definition) || $field_definition->getTargetEntityTypeId() !== 'media' || !$target_bundle) { + return FALSE; + } + return MediaType::load($target_bundle)->getSource() instanceof OEmbedInterface; + } + +} diff --git a/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php new file mode 100644 index 000000000000..306353c02d23 --- /dev/null +++ b/core/modules/media/src/Plugin/Validation/Constraint/OEmbedResourceConstraint.php @@ -0,0 +1,50 @@ +urlResolver = $url_resolver; + $this->resourceFetcher = $resource_fetcher; + $this->logger = $logger_factory->get('media'); + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('media.oembed.url_resolver'), + $container->get('media.oembed.resource_fetcher'), + $container->get('logger.factory') + ); + } + + /** + * {@inheritdoc} + */ + public function validate($value, Constraint $constraint) { + /** @var \Drupal\media\MediaInterface $media */ + $media = $value->getEntity(); + /** @var \Drupal\media\Plugin\media\Source\OEmbedInterface $source */ + $source = $media->getSource(); + + if (!($source instanceof OEmbedInterface)) { + throw new \LogicException('Media source must implement ' . OEmbedInterface::class); + } + $url = $source->getSourceFieldValue($media); + + // Ensure that the URL matches a provider. + try { + $provider = $this->urlResolver->getProviderByUrl($url); + } + catch (ResourceException $e) { + $this->handleException($e, $constraint->unknownProviderMessage); + return; + } + catch (ProviderException $e) { + $this->handleException($e, $constraint->providerErrorMessage); + return; + } + + // Ensure that the provider is allowed. + if (!in_array($provider->getName(), $source->getProviders(), TRUE)) { + $this->context->addViolation($constraint->disallowedProviderMessage, [ + '@name' => $provider->getName(), + ]); + return; + } + + // Verify that resource fetching works, because some URLs might match + // the schemes but don't support oEmbed. + try { + $endpoints = $provider->getEndpoints(); + $resource_url = reset($endpoints)->buildResourceUrl($url); + $this->resourceFetcher->fetchResource($resource_url); + } + catch (ResourceException $e) { + $this->handleException($e, $constraint->invalidResourceMessage); + } + } + + /** + * Handles exceptions that occur during validation. + * + * @param \Exception $e + * The caught exception. + * @param string $error_message + * (optional) The error message to set as a constraint violation. + */ + protected function handleException(\Exception $e, $error_message = NULL) { + if ($error_message) { + $this->context->addViolation($error_message); + } + + // The oEmbed system makes heavy use of exception wrapping, so log the + // entire exception chain to help with troubleshooting. + do { + // @todo If $e is a ProviderException or ResourceException, log additional + // debugging information contained in those exceptions in + // https://www.drupal.org/project/drupal/issues/2972846. + $this->logger->error($e->getMessage()); + $e = $e->getPrevious(); + } while ($e); + } + +} diff --git a/core/modules/media/src/Plugin/media/Source/OEmbed.php b/core/modules/media/src/Plugin/media/Source/OEmbed.php new file mode 100644 index 000000000000..a376366ce1ab --- /dev/null +++ b/core/modules/media/src/Plugin/media/Source/OEmbed.php @@ -0,0 +1,466 @@ + 'artwork', + * 'label' => t('Artwork'), + * 'description' => t('Use artwork from Flickr and DeviantArt.'), + * 'allowed_field_types' => ['string'], + * 'default_thumbnail_filename' => 'no-thumbnail.png', + * 'providers' => ['Deviantart.com', 'Flickr'], + * 'class' => 'Drupal\media\Plugin\media\Source\OEmbed', + * ]; + * } + * @endcode + * The "Deviantart.com" and "Flickr" provider names are specified in + * https://oembed.com/providers.json. The + * \Drupal\media\Plugin\media\Source\OEmbed class already knows how to handle + * standard interactions with third-party oEmbed APIs, so there is no need to + * define a new class which extends it. With the code above, you will able to + * create media types which use the "Artwork" source plugin, and use those media + * types to link to assets on Deviantart and Flickr. + * + * @MediaSource( + * id = "oembed", + * label = @Translation("oEmbed source"), + * description = @Translation("Use oEmbed URL for reusable media."), + * allowed_field_types = {"string"}, + * default_thumbnail_filename = "no-thumbnail.png", + * deriver = "Drupal\media\Plugin\media\Source\OEmbedDeriver", + * providers = {}, + * ) + */ +class OEmbed extends MediaSourceBase implements OEmbedInterface { + + /** + * The logger channel for media. + * + * @var \Drupal\Core\Logger\LoggerChannelInterface + */ + protected $logger; + + /** + * The messenger service. + * + * @var \Drupal\Core\Messenger\MessengerInterface + */ + protected $messenger; + + /** + * The HTTP client. + * + * @var \GuzzleHttp\Client + */ + protected $httpClient; + + /** + * The oEmbed resource fetcher service. + * + * @var \Drupal\media\OEmbed\ResourceFetcherInterface + */ + protected $resourceFetcher; + + /** + * The OEmbed manager service. + * + * @var \Drupal\media\OEmbed\UrlResolverInterface + */ + protected $urlResolver; + + /** + * The iFrame URL helper service. + * + * @var \Drupal\media\IFrameUrlHelper + */ + protected $iFrameUrlHelper; + + /** + * Constructs a new OEmbed instance. + * + * @param array $configuration + * A configuration array containing information about the plugin instance. + * @param string $plugin_id + * The plugin_id for the plugin instance. + * @param mixed $plugin_definition + * The plugin implementation definition. + * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager + * The entity type manager service. + * @param \Drupal\Core\Entity\EntityFieldManagerInterface $entity_field_manager + * The entity field manager service. + * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory + * The config factory service. + * @param \Drupal\Core\Field\FieldTypePluginManagerInterface $field_type_manager + * The field type plugin manager service. + * @param \Drupal\Core\Logger\LoggerChannelInterface $logger + * The logger channel for media. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger service. + * @param \GuzzleHttp\ClientInterface $http_client + * The HTTP client. + * @param \Drupal\media\OEmbed\ResourceFetcherInterface $resource_fetcher + * The oEmbed resource fetcher service. + * @param \Drupal\media\OEmbed\UrlResolverInterface $url_resolver + * The oEmbed URL resolver service. + * @param \Drupal\media\IFrameUrlHelper $iframe_url_helper + * The iFrame URL helper service. + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, ConfigFactoryInterface $config_factory, FieldTypePluginManagerInterface $field_type_manager, LoggerChannelInterface $logger, MessengerInterface $messenger, ClientInterface $http_client, ResourceFetcherInterface $resource_fetcher, UrlResolverInterface $url_resolver, IFrameUrlHelper $iframe_url_helper) { + parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $entity_field_manager, $field_type_manager, $config_factory); + $this->logger = $logger; + $this->messenger = $messenger; + $this->httpClient = $http_client; + $this->resourceFetcher = $resource_fetcher; + $this->urlResolver = $url_resolver; + $this->iFrameUrlHelper = $iframe_url_helper; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('entity_field.manager'), + $container->get('config.factory'), + $container->get('plugin.manager.field.field_type'), + $container->get('logger.factory')->get('media'), + $container->get('messenger'), + $container->get('http_client'), + $container->get('media.oembed.resource_fetcher'), + $container->get('media.oembed.url_resolver'), + $container->get('media.oembed.iframe_url_helper') + ); + } + + /** + * {@inheritdoc} + */ + public function getMetadataAttributes() { + return [ + 'type' => $this->t('Resource type'), + 'title' => $this->t('Resource title'), + 'author_name' => $this->t('The name of the author/owner'), + 'author_url' => $this->t('The URL of the author/owner'), + 'provider_name' => $this->t("The name of the provider"), + 'provider_url' => $this->t('The URL of the provider'), + 'cache_age' => $this->t('Suggested cache lifetime'), + 'default_name' => $this->t('Default name of the media item'), + 'thumbnail_uri' => $this->t('Local URI of the thumbnail'), + 'thumbnail_width' => $this->t('Thumbnail width'), + 'thumbnail_height' => $this->t('Thumbnail height'), + 'url' => $this->t('The source URL of the resource'), + 'width' => $this->t('The width of the resource'), + 'height' => $this->t('The height of the resource'), + 'html' => $this->t('The HTML representation of the resource'), + ]; + } + + /** + * {@inheritdoc} + */ + public function getMetadata(MediaInterface $media, $name) { + $media_url = $this->getSourceFieldValue($media); + + try { + $resource_url = $this->urlResolver->getResourceUrl($media_url); + $resource = $this->resourceFetcher->fetchResource($resource_url); + } + catch (ResourceException $e) { + $this->messenger->addError($e->getMessage()); + return NULL; + } + + switch ($name) { + case 'default_name': + if ($title = $this->getMetadata($media, 'title')) { + return $title; + } + elseif ($url = $this->getMetadata($media, 'url')) { + return $url; + } + return parent::getMetadata($media, 'default_name'); + + case 'thumbnail_uri': + return $this->getLocalThumbnailUri($resource) ?: parent::getMetadata($media, 'thumbnail_uri'); + + case 'type': + return $resource->getType(); + + case 'title': + return $resource->getTitle(); + + case 'author_name': + return $resource->getAuthorName(); + + case 'author_url': + return $resource->getAuthorUrl(); + + case 'provider_name': + $provider = $resource->getProvider(); + return $provider ? $provider->getName() : ''; + + case 'provider_url': + $provider = $resource->getProvider(); + return $provider ? $provider->getUrl() : NULL; + + case 'cache_age': + return $resource->getCacheMaxAge(); + + case 'thumbnail_width': + return $resource->getThumbnailWidth(); + + case 'thumbnail_height': + return $resource->getThumbnailHeight(); + + case 'url': + $url = $resource->getUrl(); + return $url ? $url->toString() : NULL; + + case 'width': + return $resource->getWidth(); + + case 'height': + return $resource->getHeight(); + + case 'html': + return $resource->getHtml(); + + default: + break; + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + + $domain = $this->configFactory->get('media.settings')->get('iframe_domain'); + if (!$this->iFrameUrlHelper->isSecure($domain)) { + array_unshift($form, [ + '#markup' => '

' . $this->t('It is potentially insecure to display oEmbed content in a frame that is served from the same domain as your main Drupal site, as this may allow execution of third-party code. You can specify a different domain for serving oEmbed content here (opens in a new window).', [ + ':url' => Url::fromRoute('media.settings')->setAbsolute()->toString(), + ]) . '

', + ]); + } + + $form['thumbnails_directory'] = [ + '#type' => 'textfield', + '#title' => $this->t('Thumbnails location'), + '#default_value' => $this->configuration['thumbnails_directory'], + '#description' => $this->t('Thumbnails will be fetched from the provider for local usage. This is the URI of the directory where they will be placed.'), + '#required' => TRUE, + ]; + + $configuration = $this->getConfiguration(); + $plugin_definition = $this->getPluginDefinition(); + + $form['providers'] = [ + '#type' => 'checkboxes', + '#title' => $this->t('Allowed providers'), + '#default_value' => $configuration['providers'], + '#options' => array_combine($plugin_definition['providers'], $plugin_definition['providers']), + '#description' => $this->t('Optionally select the allowed oEmbed providers for this media type. If left blank, all providers will be allowed.'), + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $form_state); + $configuration = $this->getConfiguration(); + $configuration['providers'] = array_filter(array_values($configuration['providers'])); + $this->setConfiguration($configuration); + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + $thumbnails_directory = $form_state->getValue('thumbnails_directory'); + if (!file_valid_uri($thumbnails_directory)) { + $form_state->setErrorByName('thumbnails_directory', $this->t('@path is not a valid path.', [ + '@path' => $thumbnails_directory, + ])); + } + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'thumbnails_directory' => 'public://oembed_thumbnails', + 'providers' => [], + ] + parent::defaultConfiguration(); + } + + /** + * Returns the local URI for a resource thumbnail. + * + * If the thumbnail is not already locally stored, this method will attempt + * to download it. + * + * @param \Drupal\media\OEmbed\Resource $resource + * The oEmbed resource. + * + * @return string|null + * The local thumbnail URI, or NULL if it could not be downloaded, or if the + * resource has no thumbnail at all. + * + * @todo Determine whether or not oEmbed media thumbnails should be stored + * locally at all, and if so, whether that functionality should be + * toggle-able. See https://www.drupal.org/project/drupal/issues/2962751 for + * more information. + */ + protected function getLocalThumbnailUri(Resource $resource) { + // If there is no remote thumbnail, there's nothing for us to fetch here. + $remote_thumbnail_url = $resource->getThumbnailUrl(); + if (!$remote_thumbnail_url) { + return NULL; + } + $remote_thumbnail_url = $remote_thumbnail_url->toString(); + + // Compute the local thumbnail URI, regardless of whether or not it exists. + $configuration = $this->getConfiguration(); + $directory = $configuration['thumbnails_directory']; + $local_thumbnail_uri = "$directory/" . Crypt::hashBase64($remote_thumbnail_url) . '.' . pathinfo($remote_thumbnail_url, PATHINFO_EXTENSION); + + // If the local thumbnail already exists, return its URI. + if (file_exists($local_thumbnail_uri)) { + return $local_thumbnail_uri; + } + + // The local thumbnail doesn't exist yet, so try to download it. First, + // ensure that the destination directory is writable, and if it's not, + // log an error and bail out. + if (!file_prepare_directory($directory, FILE_CREATE_DIRECTORY | FILE_MODIFY_PERMISSIONS)) { + $this->logger->warning('Could not prepare thumbnail destination directory @dir for oEmbed media.', [ + '@dir' => $directory, + ]); + return NULL; + } + + $error_message = 'Could not download remote thumbnail from {url}.'; + $error_context = [ + 'url' => $remote_thumbnail_url, + ]; + try { + $response = $this->httpClient->get($remote_thumbnail_url); + if ($response->getStatusCode() === 200) { + $success = file_unmanaged_save_data((string) $response->getBody(), $local_thumbnail_uri, FILE_EXISTS_REPLACE); + + if ($success) { + return $local_thumbnail_uri; + } + else { + $this->logger->warning($error_message, $error_context); + } + } + } + catch (RequestException $e) { + $this->logger->warning($e->getMessage()); + } + return NULL; + } + + /** + * {@inheritdoc} + */ + public function getSourceFieldConstraints() { + return [ + 'oembed_resource' => [], + ]; + } + + /** + * {@inheritdoc} + */ + public function prepareViewDisplay(MediaTypeInterface $type, EntityViewDisplayInterface $display) { + $display->setComponent($this->getSourceFieldDefinition($type)->getName(), [ + 'type' => 'oembed', + ]); + } + + /** + * {@inheritdoc} + */ + public function prepareFormDisplay(MediaTypeInterface $type, EntityFormDisplayInterface $display) { + parent::prepareFormDisplay($type, $display); + $source_field = $this->getSourceFieldDefinition($type)->getName(); + + $display->setComponent($source_field, [ + 'type' => 'oembed_textfield', + 'weight' => $display->getComponent($source_field)['weight'], + ]); + $display->removeComponent('name'); + } + + /** + * {@inheritdoc} + */ + public function getProviders() { + $configuration = $this->getConfiguration(); + return $configuration['providers'] ?: $this->getPluginDefinition()['providers']; + } + + /** + * {@inheritdoc} + */ + public function createSourceField(MediaTypeInterface $type) { + $plugin_definition = $this->getPluginDefinition(); + + $label = (string) $this->t('@type URL', [ + '@type' => $plugin_definition['label'], + ]); + return parent::createSourceField($type)->set('label', $label); + } + +} diff --git a/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php b/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php new file mode 100644 index 000000000000..8da3265f5b74 --- /dev/null +++ b/core/modules/media/src/Plugin/media/Source/OEmbedDeriver.php @@ -0,0 +1,32 @@ +derivatives = [ + 'video' => [ + 'id' => 'video', + 'label' => t('Remote video'), + 'description' => t('Use remote video URL for reusable media.'), + 'providers' => ['YouTube', 'Vimeo'], + 'default_thumbnail_filename' => 'video.png', + ] + $base_plugin_definition, + ]; + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php b/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php new file mode 100644 index 000000000000..90d73e520662 --- /dev/null +++ b/core/modules/media/src/Plugin/media/Source/OEmbedInterface.php @@ -0,0 +1,23 @@ + + + + {{ media|raw }} + + diff --git a/core/modules/media/tests/fixtures/oembed/photo_flickr.html b/core/modules/media/tests/fixtures/oembed/photo_flickr.html new file mode 100644 index 000000000000..6ad06d81521d --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/photo_flickr.html @@ -0,0 +1,8 @@ + + + + + + + diff --git a/core/modules/media/tests/fixtures/oembed/photo_flickr.json b/core/modules/media/tests/fixtures/oembed/photo_flickr.json new file mode 100644 index 000000000000..7cdc28e05616 --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/photo_flickr.json @@ -0,0 +1,12 @@ +{ + "type": "photo", + "title": "Druplicon FTW!", + "width": "88", + "height": "100", + "url": "internal:\/core\/misc\/druplicon.png", + "thumbnail_url": "internal:\/core\/misc\/druplicon.png", + "thumbnail_width": 88, + "thumbnail_height": 100, + "provider_name": "Flickr", + "version": "1.0" +} diff --git a/core/modules/media/tests/fixtures/oembed/providers.json b/core/modules/media/tests/fixtures/oembed/providers.json new file mode 100644 index 000000000000..e618ec40fa66 --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/providers.json @@ -0,0 +1,61 @@ +[ + { + "provider_name": "Vimeo", + "provider_url": "https:\/\/vimeo.com\/", + "endpoints": [ + { + "schemes": [ + "https:\/\/vimeo.com\/*", + "https:\/\/vimeo.com\/album\/*\/video\/*", + "https:\/\/vimeo.com\/channels\/*\/*", + "https:\/\/vimeo.com\/groups\/*\/videos\/*", + "https:\/\/vimeo.com\/ondemand\/*\/*", + "https:\/\/player.vimeo.com\/video\/*" + ], + "url": "https:\/\/vimeo.com\/api\/oembed.{format}", + "discovery": true + } + ] + }, + { + "provider_name": "Twitter", + "provider_url": "http:\/\/www.twitter.com\/", + "endpoints": [ + { + "schemes": [ + "https:\/\/twitter.com\/*\/status\/*", + "https:\/\/*.twitter.com\/*\/status\/*" + ], + "url": "https:\/\/publish.twitter.com\/oembed" + + } + ] + }, + { + "provider_name": "CollegeHumor", + "provider_url": "http:\/\/www.collegehumor.com\/", + "endpoints": [ + { + "schemes": [ + "http:\/\/www.collegehumor.com\/video\/*" + ], + "url": "http:\/\/www.collegehumor.com\/oembed.{format}", + "discovery": true + } + ] + }, + { + "provider_name": "Flickr", + "provider_url": "http:\/\/www.flickr.com\/", + "endpoints": [ + { + "schemes": [ + "http:\/\/*.flickr.com\/photos\/*", + "http:\/\/flic.kr\/p\/*" + ], + "url": "http:\/\/www.flickr.com\/services\/oembed\/", + "discovery": true + } + ] + } +] diff --git a/core/modules/media/tests/fixtures/oembed/rich_twitter.json b/core/modules/media/tests/fixtures/oembed/rich_twitter.json new file mode 100644 index 000000000000..f27b88199f1b --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/rich_twitter.json @@ -0,0 +1,13 @@ +{ + "url": "https:\/\/twitter.com\/drupaldevdays\/status\/935643039741202432", + "author_name": "Drupal Dev Days", + "author_url": "https:\/\/twitter.com\/drupaldevdays", + "html": "

By the power of Greyskull, Twitter works!

", + "width": 550, + "height": 360, + "type": "rich", + "cache_age": "3153600000", + "provider_name": "Twitter", + "provider_url": "https:\/\/twitter.com", + "version": "1.0" +} diff --git a/core/modules/media/tests/fixtures/oembed/video_collegehumor.html b/core/modules/media/tests/fixtures/oembed/video_collegehumor.html new file mode 100644 index 000000000000..fc2fdfba5246 --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/video_collegehumor.html @@ -0,0 +1,8 @@ + + + + + + + diff --git a/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml b/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml new file mode 100644 index 000000000000..696b5bf84eeb --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/video_collegehumor.xml @@ -0,0 +1,18 @@ + + + video + 1.0 + Let's Not Get a Drink Sometime + + CollegeHumor + http://www.collegehumor.com + CollegeHumor + http://www.collegehumor.com + 610 + 343 +

By the power of Greyskull, CollegeHumor works!

+ + internal:/core/misc/druplicon.png + 88 + 100 +
diff --git a/core/modules/media/tests/fixtures/oembed/video_vimeo.html b/core/modules/media/tests/fixtures/oembed/video_vimeo.html new file mode 100644 index 000000000000..f0958d02c140 --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/video_vimeo.html @@ -0,0 +1,8 @@ + + + + + + + diff --git a/core/modules/media/tests/fixtures/oembed/video_vimeo.json b/core/modules/media/tests/fixtures/oembed/video_vimeo.json new file mode 100644 index 000000000000..fac8a0d7b60c --- /dev/null +++ b/core/modules/media/tests/fixtures/oembed/video_vimeo.json @@ -0,0 +1,16 @@ +{ + "type": "video", + "version": "1.0", + "provider_name": "Vimeo", + "provider_url": "https:\/\/vimeo.com\/", + "title": "Drupal Rap Video - Schipulcon09", + "author_name": "Tendenci - The Open Source AMS", + "author_url": "https:\/\/vimeo.com\/schipul", + "html": "

By the power of Greyskull, Vimeo works!

", + "width": 480, + "height": 360, + "description": "Special thanks to Tendenci, formerly Schipul for sponsoring this video with training, equipment and time. The open source way. All creative however was self directed by the individuals - A. Hughes (www.schipul.com\/ahughes) featuring QCait (www.schipul.com\/qcait) - Hands On Drupal\n\nDrupal is a free software package that allows an individual or a community of users to easily publish, manage and organize a wide variety of content on a website.\n\nNeed a little Drupal help or just want to geek out with us? Visit our www.schipul.com\/drupal for more info - we'd love to connect!\n\nGo here for Drupal Common Terms and Suggested Modules : http:\/\/schipul.com\/en\/helpfiles\/v\/229", + "thumbnail_url": "internal:\/core\/misc\/druplicon.png", + "thumbnail_width": 295, + "thumbnail_height": 221 +} diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml new file mode 100644 index 000000000000..2ad5d2fad331 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.info.yml @@ -0,0 +1,8 @@ +name: Media oEmbed test +description: 'Provides functionality to mimic an oEmbed provider.' +type: module +package: Testing +version: VERSION +core: 8.x +dependencies: + - drupal:media diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module new file mode 100644 index 000000000000..f3b283ca632f --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.module @@ -0,0 +1,17 @@ +getName() === 'Vimeo') { + $parsed_url['query']['altered'] = 1; + } +} diff --git a/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml new file mode 100644 index 000000000000..75ce685af710 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/media_test_oembed.routing.yml @@ -0,0 +1,6 @@ +media_test_oembed.resource.get: + path: '/media_test_oembed/resource' + defaults: + _controller: '\Drupal\media_test_oembed\Controller\ResourceController::get' + requirements: + _access: 'TRUE' diff --git a/core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php b/core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php new file mode 100644 index 000000000000..ca401e72092f --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/src/Controller/ResourceController.php @@ -0,0 +1,48 @@ +query->get('url'); + + $resources = \Drupal::state()->get(static::class, []); + + $content = file_get_contents($resources[$asset_url]); + $response = new Response($content); + $response->headers->set('Content-Type', 'application/json'); + + return $response; + } + + /** + * Maps an asset URL to a local fixture representing its oEmbed resource. + * + * @param string $asset_url + * The asset URL. + * @param string $resource_path + * The path of the oEmbed resource representing the asset. + */ + public static function setResourceUrl($asset_url, $resource_path) { + $resources = \Drupal::state()->get(static::class, []); + $resources[$asset_url] = $resource_path; + \Drupal::state()->set(static::class, $resources); + } + +} diff --git a/core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php b/core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php new file mode 100644 index 000000000000..0ba3e0a7a690 --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/src/MediaTestOembedServiceProvider.php @@ -0,0 +1,26 @@ +getDefinition('media.oembed.provider_repository') + ->setClass(ProviderRepository::class); + + $container->getDefinition('media.oembed.url_resolver') + ->setClass(UrlResolver::class); + } + +} diff --git a/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php b/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php new file mode 100644 index 000000000000..dc4cb8cbf6fb --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/src/ProviderRepository.php @@ -0,0 +1,55 @@ +get(static::class) ?: parent::getAll(); + } + + /** + * {@inheritdoc} + */ + public function get($provider_name) { + $providers = \Drupal::state()->get(static::class, []); + + if (isset($providers[$provider_name])) { + return $providers[$provider_name]; + } + return parent::get($provider_name); + } + + /** + * Stores an oEmbed provider value object in state. + * + * @param \Drupal\media\OEmbed\Provider $provider + * The provider to store. + */ + public function setProvider(Provider $provider) { + $providers = \Drupal::state()->get(static::class, []); + $name = $provider->getName(); + $providers[$name] = $provider; + \Drupal::state()->set(static::class, $providers); + } + +} diff --git a/core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php b/core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php new file mode 100644 index 000000000000..acfbf8c32c3f --- /dev/null +++ b/core/modules/media/tests/modules/media_test_oembed/src/UrlResolver.php @@ -0,0 +1,38 @@ +get(static::class, []); + $urls[$url] = $endpoint_url; + \Drupal::state()->set(static::class, $urls); + } + + /** + * {@inheritdoc} + */ + public function getResourceUrl($url, $max_width = NULL, $max_height = NULL) { + $urls = \Drupal::state()->get(static::class, []); + + if (isset($urls[$url])) { + return $urls[$url]; + } + return parent::getResourceUrl($url, $max_width, $max_height); + } + +} diff --git a/core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php b/core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php new file mode 100644 index 000000000000..6f110f59624a --- /dev/null +++ b/core/modules/media/tests/src/Functional/FieldFormatter/OEmbedFormatterTest.php @@ -0,0 +1,173 @@ +lockHttpClientToFixtures(); + } + + /** + * Data provider for testRender(). + * + * @see ::testRender() + * + * @return array + */ + public function providerRender() { + return [ + 'Vimeo video' => [ + 'https://vimeo.com/7073899', + 'video_vimeo.json', + [], + [ + 'iframe' => [ + 'src' => '/media/oembed?url=https%3A//vimeo.com/7073899', + 'width' => 480, + 'height' => 360, + ], + ], + ], + 'Vimeo video, resized' => [ + 'https://vimeo.com/7073899', + 'video_vimeo.json?maxwidth=100&maxheight=100', + ['max_width' => 100, 'max_height' => 100], + [ + 'iframe' => [ + 'src' => '/media/oembed?url=https%3A//vimeo.com/7073899', + 'width' => 100, + 'height' => 100, + ], + ], + ], + 'tweet' => [ + 'https://twitter.com/drupaldevdays/status/935643039741202432', + 'rich_twitter.json', + [], + [ + 'iframe' => [ + 'src' => '/media/oembed?url=https%3A//twitter.com/drupaldevdays/status/935643039741202432', + 'width' => 550, + 'height' => 360, + ], + ], + ], + 'Flickr photo' => [ + 'https://www.flickr.com/photos/amazeelabs/26497866357', + 'photo_flickr.json', + [], + [ + 'img' => [ + 'src' => '/core/misc/druplicon.png', + 'width' => 88, + 'height' => 100, + ], + ], + ], + ]; + } + + /** + * Tests that oEmbed media types' display can be configured correctly. + */ + public function testDisplayConfiguration() { + $account = $this->drupalCreateUser(['administer media display']); + $this->drupalLogin($account); + + $media_type = $this->createMediaType([], 'oembed:video'); + $this->drupalGet('/admin/structure/media/manage/' . $media_type->id() . '/display'); + $assert = $this->assertSession(); + $assert->statusCodeEquals(200); + // Test that the formatter doesn't try to check applicability for fields + // which do not have a specific target bundle. + // @see https://www.drupal.org/project/drupal/issues/2976795. + $assert->pageTextNotContains('Can only flip STRING and INTEGER values!'); + } + + /** + * Tests the oEmbed field formatter. + * + * @param string $url + * The canonical URL of the media asset to test. + * @param string $resource_url + * The oEmebd resource URL of the media asset to test. + * @param mixed $formatter_settings + * Settings for the oEmbed field formatter. + * @param array $selectors + * An array of arrays. Each key is a CSS selector targeting an element in + * the rendered output, and each value is an array of attributes, keyed by + * name, that the element is expected to have. + * + * @dataProvider providerRender + */ + public function testRender($url, $resource_url, array $formatter_settings, array $selectors) { + $account = $this->drupalCreateUser(['view media']); + $this->drupalLogin($account); + + $media_type = $this->createMediaType([], 'oembed:video'); + + $source = $media_type->getSource(); + $source_field = $source->getSourceFieldDefinition($media_type); + + EntityViewDisplay::create([ + 'targetEntityType' => 'media', + 'bundle' => $media_type->id(), + 'mode' => 'full', + 'status' => TRUE, + ])->removeComponent('thumbnail') + ->setComponent($source_field->getName(), [ + 'type' => 'oembed', + 'settings' => $formatter_settings, + ]) + ->save(); + + $this->hijackProviderEndpoints(); + + ResourceController::setResourceUrl($url, $this->getFixturesDirectory() . '/' . $resource_url); + UrlResolver::setEndpointUrl($url, $resource_url); + + $entity = Media::create([ + 'bundle' => $media_type->id(), + $source_field->getName() => $url, + ]); + $entity->save(); + + $this->drupalGet($entity->toUrl()); + $assert = $this->assertSession(); + $assert->statusCodeEquals(200); + foreach ($selectors as $selector => $attributes) { + foreach ($attributes as $attribute => $value) { + $assert->elementAttributeContains('css', $selector, $attribute, $value); + } + } + } + +} diff --git a/core/modules/media/tests/src/Functional/MediaSettingsTest.php b/core/modules/media/tests/src/Functional/MediaSettingsTest.php new file mode 100644 index 000000000000..850d0e2b447d --- /dev/null +++ b/core/modules/media/tests/src/Functional/MediaSettingsTest.php @@ -0,0 +1,35 @@ +drupalLogin($this->createUser(['administer site configuration'])); + } + + /** + * Test that media warning appears if oEmbed media types exists. + */ + public function testStatusPage() { + $assert_session = $this->assertSession(); + + $this->drupalGet('admin/reports/status'); + $assert_session->pageTextNotContains('It is potentially insecure to display oEmbed content in a frame'); + + $this->createMediaType([], 'oembed:video'); + + $this->drupalGet('admin/reports/status'); + $assert_session->pageTextContains('It is potentially insecure to display oEmbed content in a frame'); + } + +} diff --git a/core/modules/media/tests/src/Functional/MediaTemplateSuggestionsTest.php b/core/modules/media/tests/src/Functional/MediaTemplateSuggestionsTest.php index e0e529412249..274497ae0112 100644 --- a/core/modules/media/tests/src/Functional/MediaTemplateSuggestionsTest.php +++ b/core/modules/media/tests/src/Functional/MediaTemplateSuggestionsTest.php @@ -40,7 +40,7 @@ public function testMediaThemeHookSuggestions() { $variables['elements'] = $build; $suggestions = \Drupal::moduleHandler()->invokeAll('theme_suggestions_media', [$variables]); - $this->assertEquals($suggestions, ['media__full', 'media__' . $media_type->id(), 'media__' . $media_type->id() . '__full'], 'Found expected media suggestions.'); + $this->assertEquals($suggestions, ['media__full', 'media__' . $media_type->id(), 'media__' . $media_type->id() . '__full', 'media__source_' . $media_type->getSource()->getPluginId()], 'Found expected media suggestions.'); } } diff --git a/core/modules/media/tests/src/Functional/ProviderRepositoryTest.php b/core/modules/media/tests/src/Functional/ProviderRepositoryTest.php new file mode 100644 index 000000000000..43a71053ab22 --- /dev/null +++ b/core/modules/media/tests/src/Functional/ProviderRepositoryTest.php @@ -0,0 +1,89 @@ +prophesize('\GuzzleHttp\Psr7\Response'); + $response->getBody()->willReturn($content); + + $client = $this->createMock('\GuzzleHttp\Client'); + $client->method('request')->withAnyParameters()->willReturn($response->reveal()); + $this->container->set('http_client', $client); + + $this->setExpectedException(ProviderException::class, 'Remote oEmbed providers database returned invalid or empty list.'); + $this->container->get('media.oembed.provider_repository')->getAll(); + } + + /** + * Data provider for testEmptyProviderList(). + * + * @see ::testEmptyProviderList() + * + * @return array + */ + public function providerEmptyProviderList() { + return [ + 'empty array' => ['[]'], + 'empty string' => [''], + ]; + } + + /** + * Tests that provider discovery fails with a non-existent provider database. + * + * @param string $providers_url + * The URL of the provider database. + * @param string $exception_message + * The expected exception message. + * + * @dataProvider providerNonExistingProviderDatabase + */ + public function testNonExistingProviderDatabase($providers_url, $exception_message) { + $this->config('media.settings') + ->set('oembed_providers_url', $providers_url) + ->save(); + + $this->setExpectedException(ProviderException::class, $exception_message); + $this->container->get('media.oembed.provider_repository')->getAll(); + } + + /** + * Data provider for testEmptyProviderList(). + * + * @see ::testEmptyProviderList() + * + * @return array + */ + public function providerNonExistingProviderDatabase() { + return [ + [ + 'http://oembed1.com/providers.json', + 'Could not retrieve the oEmbed provider database from http://oembed1.com/providers.json', + ], + [ + 'http://oembed.com/providers1.json', + 'Could not retrieve the oEmbed provider database from http://oembed.com/providers1.json', + ], + ]; + } + +} diff --git a/core/modules/media/tests/src/Functional/ResourceFetcherTest.php b/core/modules/media/tests/src/Functional/ResourceFetcherTest.php new file mode 100644 index 000000000000..e10ef2e20939 --- /dev/null +++ b/core/modules/media/tests/src/Functional/ResourceFetcherTest.php @@ -0,0 +1,72 @@ +useFixtureProviders(); + $this->lockHttpClientToFixtures(); + } + + /** + * Data provider for testFetchResource(). + * + * @return array + */ + public function providerFetchResource() { + return [ + 'JSON resource' => [ + 'video_vimeo.json', + 'Vimeo', + 'Drupal Rap Video - Schipulcon09', + ], + 'XML resource' => [ + 'video_collegehumor.xml', + 'CollegeHumor', + "Let's Not Get a Drink Sometime", + ], + ]; + } + + /** + * Tests resource fetching. + * + * @param string $resource_url + * The URL of the resource to fetch, relative to the base URL. + * @param string $provider_name + * The expected name of the resource provider. + * @param string $title + * The expected title of the resource. + * + * @covers ::fetchResource + * + * @dataProvider providerFetchResource + */ + public function testFetchResource($resource_url, $provider_name, $title) { + /** @var \Drupal\media\OEmbed\Resource $resource */ + $resource = $this->container->get('media.oembed.resource_fetcher') + ->fetchResource($resource_url); + + $this->assertInstanceOf(Resource::class, $resource); + $this->assertSame($provider_name, $resource->getProvider()->getName()); + $this->assertSame($title, $resource->getTitle()); + } + +} diff --git a/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php b/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php index 2dbe28be3650..336033282d68 100644 --- a/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php +++ b/core/modules/media/tests/src/Functional/Update/MediaUpdateTest.php @@ -53,4 +53,25 @@ public function testBundlePermission() { } } + /** + * Tests that media.settings config is updated with oEmbed configuration. + * + * @see media_update_8600() + */ + public function testOEmbedConfig() { + // The drupal-8.media-enabled.php fixture installs Media and all its config, + // which includes the oembed_providers_url and iframe_domain keys in + // media.settings. So, in order to prove that the update actually works, + // delete the values from config before running the update. + $this->config('media.settings') + ->clear('oembed_providers_url') + ->clear('iframe_domain') + ->save(TRUE); + + $this->runUpdates(); + $config = $this->config('media.settings'); + $this->assertSame('https://oembed.com/providers.json', $config->get('oembed_providers_url')); + $this->assertSame('', $config->get('iframe_domain')); + } + } diff --git a/core/modules/media/tests/src/Functional/UrlResolverTest.php b/core/modules/media/tests/src/Functional/UrlResolverTest.php new file mode 100644 index 000000000000..1dfe5d6abee9 --- /dev/null +++ b/core/modules/media/tests/src/Functional/UrlResolverTest.php @@ -0,0 +1,133 @@ +lockHttpClientToFixtures(); + $this->useFixtureProviders(); + } + + /** + * Data provider for testEndpointMatching(). + * + * @see ::testEndpointMatching() + * + * @return array + */ + public function providerEndpointMatching() { + return [ + 'match by endpoint: Twitter' => [ + 'https://twitter.com/Dries/status/999985431595880448', + 'https://publish.twitter.com/oembed?url=https%3A//twitter.com/Dries/status/999985431595880448', + ], + 'match by endpoint: Vimeo' => [ + 'https://vimeo.com/14782834', + 'https://vimeo.com/api/oembed.json?url=https%3A//vimeo.com/14782834', + ], + 'match by endpoint: CollegeHumor' => [ + 'http://www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime', + 'http://www.collegehumor.com/oembed.json?url=http%3A//www.collegehumor.com/video/40002870/lets-not-get-a-drink-sometime', + ], + ]; + } + + /** + * Tests resource URL resolution when the asset URL can be matched to a + * provider endpoint. + * + * @covers ::getProviderByUrl + * @covers ::getResourceUrl + * + * @param string $url + * The asset URL to resolve. + * @param string $resource_url + * The expected oEmbed resource URL of the asset. + * + * @dataProvider providerEndpointMatching + */ + public function testEndpointMatching($url, $resource_url) { + $this->assertSame( + $resource_url, + $this->container->get('media.oembed.url_resolver')->getResourceUrl($url) + ); + } + + /** + * Tests that hook_oembed_resource_url_alter() is invoked. + * + * @depends testEndpointMatching + */ + public function testResourceUrlAlterHook() { + $this->container->get('module_installer')->install(['media_test_oembed']); + + $resource_url = $this->container->get('media.oembed.url_resolver') + ->getResourceUrl('https://vimeo.com/14782834'); + + $this->assertContains('altered=1', parse_url($resource_url, PHP_URL_QUERY)); + } + + /** + * Data provider for testUrlDiscovery(). + * + * @see ::testUrlDiscovery() + * + * @return array + */ + public function providerUrlDiscovery() { + return [ + 'JSON resource' => [ + 'video_vimeo.html', + 'https://vimeo.com/api/oembed.json?url=video_vimeo.html', + ], + 'XML resource' => [ + 'video_collegehumor.html', + // The endpoint does not explicitly declare that it supports XML, so + // only JSON support is assumed, which is why the discovered URL + // contains '.json'. However, the fetched HTML file contains a + // relationship to an XML representation of the resource, with the + // application/xml+oembed MIME type. + 'http://www.collegehumor.com/oembed.json?url=video_collegehumor.html', + ], + ]; + } + + /** + * Tests URL resolution when the resource URL must be actively discovered by + * scanning the asset. + * + * @param string $url + * The asset URL to resolve. + * @param string $resource_url + * The expected oEmbed resource URL of the asset. + * + * @covers ::discoverResourceUrl + * @covers ::getProviderByUrl + * @covers ::getResourceUrl + * + * @dataProvider providerUrlDiscovery + */ + public function testUrlDiscovery($url, $resource_url) { + $this->assertSame( + $this->container->get('media.oembed.url_resolver')->getResourceUrl($url), + $resource_url + ); + } + +} diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php index 48b42916aa7c..0549d2d16cba 100644 --- a/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaDisplayTest.php @@ -54,6 +54,7 @@ public function testMediaDisplay() { // Enable the field on the display and verify it becomes visible on the UI. $this->drupalGet("/admin/structure/media/manage/{$media_type->id()}/display"); + $assert_session->buttonExists('Show row weights')->press(); $page->selectFieldOption('fields[name][region]', 'content'); $assert_session->waitForElementVisible('css', '#edit-fields-name-settings-edit'); $page->pressButton('Save'); diff --git a/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php new file mode 100644 index 000000000000..dbffaf0c4e38 --- /dev/null +++ b/core/modules/media/tests/src/FunctionalJavascript/MediaSourceOEmbedVideoTest.php @@ -0,0 +1,190 @@ +lockHttpClientToFixtures(); + } + + /** + * Tests the oembed media source. + */ + public function testMediaOEmbedVideoSource() { + $media_type_id = 'test_media_oembed_type'; + $provided_fields = [ + 'type', + 'title', + 'default_name', + 'author_name', + 'author_url', + 'provider_name', + 'provider_url', + 'cache_age', + 'thumbnail_uri', + 'thumbnail_width', + 'thumbnail_height', + 'url', + 'width', + 'height', + 'html', + ]; + + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + + $this->doTestCreateMediaType($media_type_id, 'oembed:video', $provided_fields); + + // Create custom fields for the media type to store metadata attributes. + $fields = [ + 'field_string_width' => 'string', + 'field_string_height' => 'string', + 'field_string_author_name' => 'string', + ]; + $this->createMediaTypeFields($fields, $media_type_id); + + // Hide the name field widget to test default name generation. + $this->hideMediaTypeFieldWidget('name', $media_type_id); + + $this->drupalGet("admin/structure/media/manage/$media_type_id"); + // Only accept Vimeo videos. + $page->checkField("source_configuration[providers][Vimeo]"); + $assert_session->selectExists('field_map[width]')->setValue('field_string_width'); + $assert_session->selectExists('field_map[height]')->setValue('field_string_height'); + $assert_session->selectExists('field_map[author_name]')->setValue('field_string_author_name'); + $assert_session->buttonExists('Save')->press(); + + $this->hijackProviderEndpoints(); + $video_url = 'https://vimeo.com/7073899'; + ResourceController::setResourceUrl($video_url, $this->getFixturesDirectory() . '/video_vimeo.json'); + + // Create a media item. + $this->drupalGet("media/add/$media_type_id"); + $assert_session->fieldExists('Remote video URL')->setValue($video_url); + $assert_session->buttonExists('Save')->press(); + + $assert_session->addressEquals('media/1'); + /** @var \Drupal\media\MediaInterface $media */ + $media = Media::load(1); + + // The thumbnail should have been downloaded. + $thumbnail = $media->getSource()->getMetadata($media, 'thumbnail_uri'); + $this->assertFileExists($thumbnail); + + // Ensure the iframe exists and that its src attribute contains a coherent + // URL with the query parameters we expect. + $iframe_url = $assert_session->elementExists('css', 'iframe')->getAttribute('src'); + $iframe_url = parse_url($iframe_url); + $this->assertStringEndsWith('/media/oembed', $iframe_url['path']); + $this->assertNotEmpty($iframe_url['query']); + $query = []; + parse_str($iframe_url['query'], $query); + $this->assertSame($video_url, $query['url']); + $this->assertNotEmpty($query['hash']); + + // Make sure the thumbnail is displayed from uploaded image. + $assert_session->elementAttributeContains('css', '.image-style-thumbnail', 'src', '/oembed_thumbnails/' . basename($thumbnail)); + + // Load the media and check that all fields are properly populated. + $media = Media::load(1); + $this->assertSame('Drupal Rap Video - Schipulcon09', $media->getName()); + $this->assertSame('480', $media->field_string_width->value); + $this->assertSame('360', $media->field_string_height->value); + + // Try to create a media asset from a disallowed provider. + $this->drupalGet("media/add/$media_type_id"); + $assert_session->fieldExists('Remote video URL')->setValue('http://www.collegehumor.com/video/40003213/grant-and-katie-are-starting-their-own-company'); + $page->pressButton('Save'); + + $assert_session->pageTextContains('The CollegeHumor provider is not allowed.'); + + // Test anonymous access to media via iframe. + $this->drupalLogout(); + + // Without a hash should be denied. + $no_hash_query = array_diff_key($query, ['hash' => '']); + $this->drupalGet('media/oembed', ['query' => $no_hash_query]); + $assert_session->pageTextNotContains('By the power of Greyskull, Vimeo works!'); + $assert_session->pageTextContains('Access denied'); + + // A correct query should be allowed because the anonymous role has the + // 'view media' permission. + $this->drupalGet('media/oembed', ['query' => $query]); + $assert_session->pageTextContains('By the power of Greyskull, Vimeo works!'); + + // Remove the 'view media' permission to test that this restricts access. + $role = Role::load(AccountInterface::ANONYMOUS_ROLE); + $role->revokePermission('view media'); + $role->save(); + $this->drupalGet('media/oembed', ['query' => $query]); + $assert_session->pageTextNotContains('By the power of Greyskull, Vimeo works!'); + $assert_session->pageTextContains('Access denied'); + } + + /** + * Test that a security warning appears if iFrame domain is not set. + */ + public function testOEmbedSecurityWarning() { + $media_type_id = 'test_media_oembed_type'; + $source_id = 'oembed:video'; + + $session = $this->getSession(); + $page = $session->getPage(); + $assert_session = $this->assertSession(); + + $this->drupalGet('admin/structure/media/add'); + $page->fillField('label', $media_type_id); + $this->getSession() + ->wait(5000, "jQuery('.machine-name-value').text() === '{$media_type_id}'"); + + // Make sure the source is available. + $assert_session->fieldExists('Media source'); + $assert_session->optionExists('Media source', $source_id); + $page->selectFieldOption('Media source', $source_id); + $result = $assert_session->waitForElementVisible('css', 'fieldset[data-drupal-selector="edit-source-configuration"]'); + $this->assertNotEmpty($result); + + $assert_session->pageTextContains('It is potentially insecure to display oEmbed content in a frame'); + + $this->config('media.settings')->set('iframe_domain', 'http://example.com')->save(); + + $this->drupalGet('admin/structure/media/add'); + $page->fillField('label', $media_type_id); + $this->getSession() + ->wait(5000, "jQuery('.machine-name-value').text() === '{$media_type_id}'"); + + // Make sure the source is available. + $assert_session->fieldExists('Media source'); + $assert_session->optionExists('Media source', $source_id); + $page->selectFieldOption('Media source', $source_id); + $result = $assert_session->waitForElementVisible('css', 'fieldset[data-drupal-selector="edit-source-configuration"]'); + $this->assertNotEmpty($result); + + $assert_session->pageTextNotContains('It is potentially insecure to display oEmbed content in a frame'); + } + +} diff --git a/core/modules/media/tests/src/Kernel/OEmbedIframeControllerTest.php b/core/modules/media/tests/src/Kernel/OEmbedIframeControllerTest.php new file mode 100644 index 000000000000..84fe4397daae --- /dev/null +++ b/core/modules/media/tests/src/Kernel/OEmbedIframeControllerTest.php @@ -0,0 +1,56 @@ + [ + '', + ], + 'invalid hash' => [ + $this->randomString(), + ], + ]; + } + + /** + * Tests validation of the 'hash' query string parameter. + * + * @param string $hash + * The 'hash' query string parameter. + * + * @dataProvider providerBadHashParameter + * + * @covers ::render + */ + public function testBadHashParameter($hash) { + /** @var callable $controller */ + $controller = $this->container + ->get('controller_resolver') + ->getControllerFromDefinition('\Drupal\media\Controller\OEmbedIframeController::render'); + + $this->assertInternalType('callable', $controller); + + $this->setExpectedException('\Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException', 'This resource is not available'); + $request = new Request([ + 'url' => 'https://example.com/path/to/resource', + 'hash' => $hash, + ]); + $controller($request); + } + +} diff --git a/core/modules/media/tests/src/Traits/OEmbedTestTrait.php b/core/modules/media/tests/src/Traits/OEmbedTestTrait.php new file mode 100644 index 000000000000..a3699ff69072 --- /dev/null +++ b/core/modules/media/tests/src/Traits/OEmbedTestTrait.php @@ -0,0 +1,89 @@ +baseUrl . '/' . $this->getFixturesDirectory(); + } + + /** + * Forces Media to use the provider database in the fixtures directory. + */ + protected function useFixtureProviders() { + $this->config('media.settings') + ->set('oembed_providers_url', $this->getFixturesUrl() . '/providers.json') + ->save(); + } + + /** + * Configures the http_client service so that all requests are carried out + * relative to the URL of the fixtures directory. For example, after calling + * this method, a request for foobar.html will actually request + * http://test-site/path/to/fuxtures/foobar.html. + */ + protected function lockHttpClientToFixtures() { + $this->writeSettings([ + 'settings' => [ + 'http_client_config' => [ + 'base_uri' => (object) [ + 'value' => $this->getFixturesUrl() . '/', + 'required' => TRUE, + ], + ], + ], + ]); + } + + /** + * Ensures that all oEmbed provider endpoints defined in the fixture + * providers.json will use the media_test_oembed.resource.get route as their + * URL. + * + * This requires the media_test_oembed module in order to work. + */ + protected function hijackProviderEndpoints() { + $providers = $this->getFixturesDirectory() . '/providers.json'; + $providers = file_get_contents($providers); + $providers = Json::decode($providers); + + $endpoint_url = Url::fromRoute('media_test_oembed.resource.get') + ->setAbsolute() + ->toString(); + + /** @var \Drupal\media_test_oembed\ProviderRepository $provider_repository */ + $provider_repository = $this->container->get('media.oembed.provider_repository'); + + foreach ($providers as &$provider) { + foreach ($provider['endpoints'] as &$endpoint) { + $endpoint['url'] = $endpoint_url; + } + $provider_repository->setProvider( + new Provider($provider['provider_name'], $provider['provider_url'], $provider['endpoints']) + ); + } + } + +} diff --git a/core/modules/media/tests/src/Unit/IFrameUrlHelperTest.php b/core/modules/media/tests/src/Unit/IFrameUrlHelperTest.php new file mode 100644 index 000000000000..99a1362b486f --- /dev/null +++ b/core/modules/media/tests/src/Unit/IFrameUrlHelperTest.php @@ -0,0 +1,89 @@ + [ + '/path/to/media.php', + 'http://www.example.com/', + FALSE, + ], + 'no base URL domain' => [ + 'http://www.example.com/media.php', + '/invalid/base/url', + FALSE, + ], + 'same domain' => [ + 'http://www.example.com/media.php', + 'http://www.example.com/', + FALSE, + ], + 'different domain' => [ + 'http://www.example.com/media.php', + 'http://www.example-assets.com/', + TRUE, + ], + 'same subdomain' => [ + 'http://foo.example.com/media.php', + 'http://foo.example.com/', + FALSE, + ], + 'different subdomain' => [ + 'http://assets.example.com/media.php', + 'http://foo.example.com/', + TRUE, + ], + 'subdomain and top-level domain' => [ + 'http://assets.example.com/media.php', + 'http://example.com/', + TRUE, + ], + ]; + } + + /** + * Tests that isSecure() behaves properly. + * + * @param string $url + * The URL to test for security. + * @param string $base_url + * The base URL to compare $url against. + * @param bool $secure + * The expected result of isSecure(). + * + * @covers ::isSecure + * + * @dataProvider providerIsSecure + */ + public function testIsSecure($url, $base_url, $secure) { + $request_context = $this->prophesize(RequestContext::class); + $request_context->getCompleteBaseUrl()->willReturn($base_url); + $url_helper = new IFrameUrlHelper( + $request_context->reveal(), + $this->prophesize(PrivateKey::class)->reveal() + ); + + $this->assertSame($secure, $url_helper->isSecure($url)); + } + +} diff --git a/core/themes/stable/templates/content/media-oembed-iframe.html.twig b/core/themes/stable/templates/content/media-oembed-iframe.html.twig new file mode 100644 index 000000000000..96de5dfbad57 --- /dev/null +++ b/core/themes/stable/templates/content/media-oembed-iframe.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Default theme implementation to display an oEmbed resource in an iframe. + * + * @ingroup themeable + */ +#} + + + + {{ media|raw }} + + From 522ab1710138d114bdbafb34764d91ad7f57691c Mon Sep 17 00:00:00 2001 From: Alex Pott Date: Thu, 21 Jun 2018 09:16:23 +0100 Subject: [PATCH 02/39] Issue #2828528 by Wim Leers, droplet, ZeiP, vaplas, tacituseu, samuel.mortenson, michielnugter: Add Quick Edit Functional JS test coverage --- .../QuickEditImageEditorTestTrait.php | 76 ++++ .../QuickEditImageTest.php | 121 +++--- .../QuickEditIntegrationTest.php | 343 ++++++++++++++++++ .../QuickEditJavascriptTestBase.php | 326 +++++++++++++++++ 4 files changed, 822 insertions(+), 44 deletions(-) create mode 100644 core/modules/image/tests/src/FunctionalJavascript/QuickEditImageEditorTestTrait.php create mode 100644 core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditIntegrationTest.php create mode 100644 core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditJavascriptTestBase.php diff --git a/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageEditorTestTrait.php b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageEditorTestTrait.php new file mode 100644 index 000000000000..704e4e702014 --- /dev/null +++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageEditorTestTrait.php @@ -0,0 +1,76 @@ +assertJsCondition('document.querySelector(".quickedit-image-field-info") !== null', 10000); + + $quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar'); + $this->assertNotNull($quickedit_entity_toolbar->find('css', 'form.quickedit-image-field-info input[name="alt"]')); + } + + /** + * Simulates typing in the 'image' in-place editor 'alt' attribute text input. + * + * @param string $text + * The text to type. + */ + protected function typeInImageEditorAltTextInput($text) { + $quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar'); + $input = $quickedit_entity_toolbar->find('css', 'form.quickedit-image-field-info input[name="alt"]'); + $input->setValue($text); + } + + /** + * Simulates dragging and dropping an image on the 'image' in-place editor. + * + * @param string $file_uri + * The URI of the image file to drag and drop. + */ + protected function dropImageOnImageEditor($file_uri) { + // Our headless browser can't drag+drop files, but we can mock the event. + // Append a hidden upload element to the DOM. + $script = 'jQuery("").appendTo("body")'; + $this->getSession()->executeScript($script); + + // Find the element, and set its value to our new image. + $input = $this->assertSession()->elementExists('css', '#quickedit-image-test-input'); + $filepath = $this->container->get('file_system')->realpath($file_uri); + $input->attachFile($filepath); + + // Trigger the upload logic with a mock "drop" event. + $script = 'var e = jQuery.Event("drop");' + . 'e.originalEvent = {dataTransfer: {files: jQuery("#quickedit-image-test-input").get(0).files}};' + . 'e.preventDefault = e.stopPropagation = function () {};' + . 'jQuery(".quickedit-image-dropzone").trigger(e);'; + $this->getSession()->executeScript($script); + + // Wait for the dropzone element to be removed (i.e. loading is done). + $js_condition = <<assertJsCondition($js_condition, 20000); + + } + +} diff --git a/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php index 12e43e75850b..b4444eff2b09 100644 --- a/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php +++ b/core/modules/image/tests/src/FunctionalJavascript/QuickEditImageTest.php @@ -3,24 +3,24 @@ namespace Drupal\Tests\image\FunctionalJavascript; use Drupal\file\Entity\File; -use Drupal\FunctionalJavascriptTests\JavascriptTestBase; use Drupal\Tests\image\Kernel\ImageFieldCreationTrait; +use Drupal\Tests\quickedit\FunctionalJavascript\QuickEditJavascriptTestBase; use Drupal\Tests\TestFileCreationTrait; /** - * Tests the JavaScript functionality of the "image" in-place editor. - * + * @coversDefaultClass \Drupal\image\Plugin\InPlaceEditor\Image * @group image */ -class QuickEditImageTest extends JavascriptTestBase { +class QuickEditImageTest extends QuickEditJavascriptTestBase { use ImageFieldCreationTrait; use TestFileCreationTrait; + use QuickEditImageEditorTestTrait; /** * {@inheritdoc} */ - public static $modules = ['node', 'image', 'field_ui', 'contextual', 'quickedit', 'toolbar']; + public static $modules = ['node', 'image', 'field_ui']; /** * A user with permissions to edit Articles and use Quick Edit. @@ -52,9 +52,12 @@ protected function setUp() { } /** - * Tests if an image can be uploaded inline with Quick Edit. + * Test that quick editor works correctly with images. + * + * @covers ::isCompatible + * @covers ::getAttachments */ - public function testUpload() { + public function testImageInPlaceEditor() { // Create a field with a basic filetype restriction. $field_name = strtolower($this->randomMachineName()); $field_settings = [ @@ -114,52 +117,82 @@ public function testUpload() { // Assert that the initial image is present. $this->assertSession()->elementExists('css', $entity_selector . ' ' . $field_selector . ' ' . $original_image_selector); - // Wait until Quick Edit loads. - $condition = "jQuery('" . $entity_selector . " .quickedit').length > 0"; - $this->assertJsCondition($condition, 10000); - - // Initiate Quick Editing. - $this->click('.contextual-toolbar-tab button'); - $this->click($entity_selector . ' [data-contextual-id] > button'); - $this->click($entity_selector . ' [data-contextual-id] .quickedit > a'); - $this->click($field_selector); + // Initial state. + $this->awaitQuickEditForEntity('node', 1); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'closed', + ]); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'inactive', + 'node/1/uid/en/full' => 'inactive', + 'node/1/created/en/full' => 'inactive', + 'node/1/body/en/full' => 'inactive', + 'node/1/' . $field_name . '/en/full' => 'inactive', + ]); - // Wait for the field info to load and set new alt text. - $condition = "jQuery('.quickedit-image-field-info').length > 0"; - $this->assertJsCondition($condition, 10000); - $input = $this->assertSession()->elementExists('css', '.quickedit-image-field-info input[name="alt"]'); - $input->setValue('New text'); + // Start in-place editing of the article node. + $this->startQuickEditViaToolbar('node', 1, 0); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'opened', + ]); + $this->assertQuickEditEntityToolbar((string) $node->label(), NULL); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'candidate', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/' . $field_name . '/en/full' => 'candidate', + ]); - // Check that our Dropzone element exists. + // Click the image field. + $this->click($field_selector); + $this->awaitImageEditor(); $this->assertSession()->elementExists('css', $field_selector . ' .quickedit-image-dropzone'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'candidate', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/' . $field_name . '/en/full' => 'active', + ]); - // Our headless browser can't drag+drop files, but we can mock the event. - // Append a hidden upload element to the DOM. - $script = 'jQuery("").appendTo("body")'; - $this->getSession()->executeScript($script); - - // Find the element, and set its value to our new image. - $input = $this->assertSession()->elementExists('css', '#quickedit-image-test-input'); - $filepath = $this->container->get('file_system')->realpath($valid_images[1]->uri); - $input->attachFile($filepath); - - // Trigger the upload logic with a mock "drop" event. - $script = 'var e = jQuery.Event("drop");' - . 'e.originalEvent = {dataTransfer: {files: jQuery("#quickedit-image-test-input").get(0).files}};' - . 'e.preventDefault = e.stopPropagation = function () {};' - . 'jQuery(".quickedit-image-dropzone").trigger(e);'; - $this->getSession()->executeScript($script); + // Type new 'alt' text. + $this->typeInImageEditorAltTextInput('New text'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'candidate', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/' . $field_name . '/en/full' => 'changed', + ]); - // Wait for the dropzone element to be removed (i.e. loading is done). - $condition = "jQuery('" . $field_selector . " .quickedit-image-dropzone').length == 0"; - $this->assertJsCondition($condition, 20000); + // Drag and drop an image. + $this->dropImageOnImageEditor($valid_images[1]->uri); // To prevent 403s on save, we re-set our request (cookie) state. $this->prepareRequest(); - // Save the change. - $this->click('.quickedit-button.action-save'); - $this->assertSession()->assertWaitOnAjaxRequest(); + // Click 'Save'. + $this->saveQuickEdit(); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'committing', + ]); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'candidate', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/' . $field_name . '/en/full' => 'saving', + ]); + $this->assertEntityInstanceFieldMarkup('node', 1, 0, [ + 'node/1/' . $field_name . '/en/full' => '.quickedit-changed', + ]); + + // Wait for the saving of the image field to complete. + $this->assertJsCondition("Drupal.quickedit.collections.entities.get('node/1[0]').get('state') === 'closed'"); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'closed', + ]); // Re-visit the page to make sure the edit worked. $this->drupalGet('node/' . $node->id()); diff --git a/core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditIntegrationTest.php b/core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditIntegrationTest.php new file mode 100644 index 000000000000..cb3f141b067e --- /dev/null +++ b/core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditIntegrationTest.php @@ -0,0 +1,343 @@ + 'some_format', + 'name' => 'Some format', + 'weight' => 0, + 'filters' => [ + 'filter_html' => [ + 'status' => 1, + 'settings' => [ + 'allowed_html' => '


', + ], + ], + ], + ])->save(); + Editor::create([ + 'format' => 'some_format', + 'editor' => 'ckeditor', + ])->save(); + + // Create the Article node type. + $this->drupalCreateContentType(['type' => 'article', 'name' => 'Article']); + + // Add "tags" vocabulary + field to the Article node type. + $vocabulary = Vocabulary::create([ + 'name' => 'Tags', + 'vid' => 'tags', + ]); + $vocabulary->save(); + $field_name = 'field_' . $vocabulary->id(); + $handler_settings = [ + 'target_bundles' => [ + $vocabulary->id() => $vocabulary->id(), + ], + 'auto_create' => TRUE, + ]; + $this->createEntityReferenceField('node', 'article', $field_name, 'Tags', 'taxonomy_term', 'default', $handler_settings, FieldStorageDefinitionInterface::CARDINALITY_UNLIMITED); + + // Add formatter & widget for "tags" field. + \Drupal::entityTypeManager() + ->getStorage('entity_form_display') + ->load('node.article.default') + ->setComponent($field_name, ['type' => 'entity_reference_autocomplete_tags']) + ->save(); + \Drupal::entityTypeManager() + ->getStorage('entity_view_display') + ->load('node.article.default') + ->setComponent($field_name, ['type' => 'entity_reference_label']) + ->save(); + + $this->drupalPlaceBlock('page_title_block'); + $this->drupalPlaceBlock('system_main_block'); + + // Log in as a content author who can use Quick Edit and edit Articles. + $this->contentAuthorUser = $this->drupalCreateUser([ + 'access contextual links', + 'access toolbar', + 'access in-place editing', + 'access content', + 'create article content', + 'edit any article content', + 'use text format some_format', + 'edit terms in tags', + 'administer blocks', + ]); + $this->drupalLogin($this->contentAuthorUser); + } + + /** + * Tests if an article node can be in-place edited with Quick Edit. + */ + public function testArticleNode() { + $term = Term::create([ + 'name' => 'foo', + 'vid' => 'tags', + ]); + $term->save(); + + $node = $this->drupalCreateNode([ + 'type' => 'article', + 'title' => t('My Test Node'), + 'body' => [ + 'value' => '

Hello world!

I do not know what to say…

I wish I were eloquent.

', + 'format' => 'some_format', + ], + 'field_tags' => [ + ['target_id' => $term->id()], + ], + ]); + + $this->drupalGet('node/' . $node->id()); + + // Initial state. + $this->awaitQuickEditForEntity('node', 1); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'closed', + ]); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'inactive', + 'node/1/uid/en/full' => 'inactive', + 'node/1/created/en/full' => 'inactive', + 'node/1/body/en/full' => 'inactive', + 'node/1/field_tags/en/full' => 'inactive', + ]); + + // Start in-place editing of the article node. + $this->startQuickEditViaToolbar('node', 1, 0); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'opened', + ]); + $this->assertQuickEditEntityToolbar((string) $node->label(), NULL); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'candidate', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'candidate', + ]); + + $assert_session = $this->assertSession(); + + // Click the title field. + $this->click('[data-quickedit-field-id="node/1/title/en/full"].quickedit-candidate'); + $assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="title"]'); + $this->assertQuickEditEntityToolbar((string) $node->label(), 'Title'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'active', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'candidate', + ]); + $this->assertEntityInstanceFieldMarkup('node', 1, 0, [ + 'node/1/title/en/full' => '[contenteditable="true"]', + ]); + + // Append something to the title. + $this->typeInPlainTextEditor('[data-quickedit-field-id="node/1/title/en/full"].quickedit-candidate', ' Llamas are awesome!'); + $this->awaitEntityInstanceFieldState('node', 1, 0, 'title', 'en', 'changed'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'changed', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'candidate', + ]); + + // Click the body field. + hold_test_response(TRUE); + $this->click('[data-quickedit-entity-id="node/1"] .field--name-body'); + $assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="body"]'); + $this->assertQuickEditEntityToolbar((string) $node->label(), 'Body'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/title/en/full' => 'saving', + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'active', + 'node/1/field_tags/en/full' => 'candidate', + ]); + hold_test_response(FALSE); + + // Wait for CKEditor to load, then verify it has. + $this->assertJsCondition('CKEDITOR.status === "loaded"'); + $this->assertEntityInstanceFieldMarkup('node', 1, 0, [ + 'node/1/body/en/full' => '.cke_editable_inline', + 'node/1/field_tags/en/full' => ':not(.quickedit-editor-is-popup)', + ]); + $this->assertSession()->elementExists('css', '#quickedit-entity-toolbar .quickedit-toolgroup.wysiwyg-main > .cke_chrome .cke_top[role="presentation"] .cke_toolbar[role="toolbar"] .cke_toolgroup[role="presentation"] > .cke_button[title~="Bold"][role="button"]'); + + // Wait for the validating & saving of the title to complete. + $this->awaitEntityInstanceFieldState('node', 1, 0, 'title', 'en', 'candidate'); + + // Click the tags field. + hold_test_response(TRUE); + $this->click('[data-quickedit-field-id="node/1/field_tags/en/full"]'); + $assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="tags"]'); + $this->assertQuickEditEntityToolbar((string) $node->label(), 'Tags'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'activating', + 'node/1/title/en/full' => 'candidate', + ]); + $this->assertEntityInstanceFieldMarkup('node', 1, 0, [ + 'node/1/title/en/full' => '.quickedit-changed', + 'node/1/field_tags/en/full' => '.quickedit-editor-is-popup', + ]); + // Assert the "Loading…" popup appears. + $this->assertSession()->elementExists('css', '.quickedit-form-container > .quickedit-form[role="dialog"] > .placeholder'); + hold_test_response(FALSE); + // Wait for the form to load. + $this->assertJsCondition('document.querySelector(\'.quickedit-form-container > .quickedit-form[role="dialog"] > .placeholder\') === null'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'active', + 'node/1/title/en/full' => 'candidate', + ]); + + // Enter an additional tag. + $this->typeInFormEditorTextInputField('field_tags[target_id]', 'foo, bar'); + $this->awaitEntityInstanceFieldState('node', 1, 0, 'field_tags', 'en', 'changed'); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'changed', + 'node/1/title/en/full' => 'candidate', + ]); + + // Click 'Save'. + hold_test_response(TRUE); + $this->saveQuickEdit(); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'committing', + ]); + $this->assertEntityInstanceFieldStates('node', 1, 0, [ + 'node/1/uid/en/full' => 'candidate', + 'node/1/created/en/full' => 'candidate', + 'node/1/body/en/full' => 'candidate', + 'node/1/field_tags/en/full' => 'saving', + 'node/1/title/en/full' => 'candidate', + ]); + hold_test_response(FALSE); + $this->assertEntityInstanceFieldMarkup('node', 1, 0, [ + 'node/1/title/en/full' => '.quickedit-changed', + 'node/1/field_tags/en/full' => '.quickedit-changed', + ]); + + // Wait for the saving of the tags field to complete. + $this->assertJsCondition("Drupal.quickedit.collections.entities.get('node/1[0]').get('state') === 'closed'"); + $this->assertEntityInstanceStates([ + 'node/1[0]' => 'closed', + ]); + } + + /** + * Tests if a custom can be in-place edited with Quick Edit. + */ + public function testCustomBlock() { + $block_content_type = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'basic', + 'revision' => FALSE, + ]); + $block_content_type->save(); + block_content_add_body_field($block_content_type->id()); + + $block_content = BlockContent::create([ + 'info' => 'Llama', + 'type' => 'basic', + 'body' => [ + 'value' => 'The name "llama" was adopted by European settlers from native Peruvians.', + 'format' => 'some_format', + ], + ]); + $block_content->save(); + $this->drupalPlaceBlock('block_content:' . $block_content->uuid(), [ + 'label' => 'My custom block!', + ]); + + $this->drupalGet(''); + + // Initial state. + $this->awaitQuickEditForEntity('block_content', 1); + $this->assertEntityInstanceStates([ + 'block_content/1[0]' => 'closed', + ]); + + // Start in-place editing of the article node. + $this->startQuickEditViaToolbar('block_content', 1, 0); + $this->assertEntityInstanceStates([ + 'block_content/1[0]' => 'opened', + ]); + $this->assertQuickEditEntityToolbar((string) $block_content->label(), 'Body'); + $this->assertEntityInstanceFieldStates('block_content', 1, 0, [ + 'block_content/1/body/en/full' => 'highlighted', + ]); + + // Click the body field. + $this->click('[data-quickedit-entity-id="block_content/1"] .field--name-body'); + $assert_session = $this->assertSession(); + $assert_session->waitForElement('css', '.quickedit-toolbar-field div[id*="body"]'); + $this->assertQuickEditEntityToolbar((string) $block_content->label(), 'Body'); + $this->assertEntityInstanceFieldStates('block_content', 1, 0, [ + 'block_content/1/body/en/full' => 'active', + ]); + + // Wait for CKEditor to load, then verify it has. + $this->assertJsCondition('CKEDITOR.status === "loaded"'); + $this->assertEntityInstanceFieldMarkup('block_content', 1, 0, [ + 'block_content/1/body/en/full' => '.cke_editable_inline', + ]); + $this->assertSession()->elementExists('css', '#quickedit-entity-toolbar .quickedit-toolgroup.wysiwyg-main > .cke_chrome .cke_top[role="presentation"] .cke_toolbar[role="toolbar"] .cke_toolgroup[role="presentation"] > .cke_button[title~="Bold"][role="button"]'); + } + +} diff --git a/core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditJavascriptTestBase.php b/core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditJavascriptTestBase.php new file mode 100644 index 000000000000..cb40a83cecae --- /dev/null +++ b/core/modules/quickedit/tests/src/FunctionalJavascript/QuickEditJavascriptTestBase.php @@ -0,0 +1,326 @@ + '.quickedit-field:not(.quickedit-editable):not(.quickedit-candidate):not(.quickedit-highlighted):not(.quickedit-editing):not(.quickedit-changed)', + // A field in 'candidate' state may still have the .quickedit-changed class + // because when its changes were saved to tempstore, it'll still be changed. + // It's just not currently being edited, so that's why it is not in the + // 'changed' state. + 'candidate' => '.quickedit-field.quickedit-editable.quickedit-candidate:not(.quickedit-highlighted):not(.quickedit-editing)', + 'highlighted' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted:not(.quickedit-editing)', + 'activating' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing:not(.quickedit-changed)', + 'active' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing:not(.quickedit-changed)', + 'changed' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing.quickedit-changed', + 'saving' => '.quickedit-field.quickedit-editable.quickedit-candidate.quickedit-highlighted.quickedit-editing.quickedit-changed', + ]; + + /** + * Starts in-place editing of the given entity instance. + * + * @param string $entity_type_id + * The entity type ID. + * @param int $entity_id + * The entity ID. + * @param int $entity_instance_id + * The entity instance ID. (Instance on the page.) + */ + protected function startQuickEditViaToolbar($entity_type_id, $entity_id, $entity_instance_id) { + $page = $this->getSession()->getPage(); + + $toolbar_edit_button_selector = '#toolbar-bar .contextual-toolbar-tab button'; + $entity_instance_selector = '[data-quickedit-entity-id="' . $entity_type_id . '/' . $entity_id . '"][data-quickedit-entity-instance-id="' . $entity_instance_id . '"]'; + $contextual_links_trigger_selector = '[data-contextual-id] > .trigger'; + + // Assert the original page state does not have the toolbar's "Edit" button + // pressed/activated, and hence none of the contextual link triggers should + // be visible. + $toolbar_edit_button = $page->find('css', $toolbar_edit_button_selector); + $this->assertSame('false', $toolbar_edit_button->getAttribute('aria-pressed'), 'The "Edit" button in the toolbar is not yet pressed.'); + $this->assertFalse($toolbar_edit_button->hasClass('is-active'), 'The "Edit" button in the toolbar is not yet marked as active.'); + foreach ($page->findAll('css', $contextual_links_trigger_selector) as $dom_node) { + /** @var \Behat\Mink\Element\NodeElement $dom_node */ + $this->assertTrue($dom_node->hasClass('visually-hidden'), 'The contextual links trigger "' . $dom_node->getParent()->getAttribute('data-contextual-id') . '" is hidden.'); + } + $this->assertTrue(TRUE, 'All contextual links triggers are hidden.'); + + // Click the "Edit" button in the toolbar. + $this->click($toolbar_edit_button_selector); + + // Assert the toolbar's "Edit" button is now pressed/activated, and hence + // all of the contextual link triggers should be visible. + $this->assertSame('true', $toolbar_edit_button->getAttribute('aria-pressed'), 'The "Edit" button in the toolbar is pressed.'); + $this->assertTrue($toolbar_edit_button->hasClass('is-active'), 'The "Edit" button in the toolbar is marked as active.'); + foreach ($page->findAll('css', $contextual_links_trigger_selector) as $dom_node) { + /** @var \Behat\Mink\Element\NodeElement $dom_node */ + $this->assertFalse($dom_node->hasClass('visually-hidden'), 'The contextual links trigger "' . $dom_node->getParent()->getAttribute('data-contextual-id') . '" is visible.'); + } + $this->assertTrue(TRUE, 'All contextual links triggers are visible.'); + + // @todo Press tab key to verify that tabbing is now contrained to only + // contextual links triggers: https://www.drupal.org/node/2834776 + + // Assert that the contextual links associated with the entity's contextual + // links trigger are not visible. + /** @var \Behat\Mink\Element\NodeElement $entity_contextual_links_container */ + $entity_contextual_links_container = $page->find('css', $entity_instance_selector) + ->find('css', $contextual_links_trigger_selector) + ->getParent(); + $this->assertFalse($entity_contextual_links_container->hasClass('open')); + $this->assertTrue($entity_contextual_links_container->find('css', 'ul.contextual-links')->hasAttribute('hidden')); + + // Click the contextual link trigger for the entity we want to Quick Edit. + $this->click($entity_instance_selector . ' ' . $contextual_links_trigger_selector); + + $this->assertTrue($entity_contextual_links_container->hasClass('open')); + $this->assertFalse($entity_contextual_links_container->find('css', 'ul.contextual-links')->hasAttribute('hidden')); + + // Click the "Quick edit" contextual link. + $this->click($entity_instance_selector . ' [data-contextual-id] ul.contextual-links li.quickedit a'); + + // Assert the Quick Edit internal state is correct. + $js_condition = <<assertJsCondition($js_condition); + } + + /** + * Clicks the 'Save' button in the Quick Edit entity toolbar. + */ + protected function saveQuickEdit() { + $quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar'); + $save_button = $quickedit_entity_toolbar->find('css', 'button.action-save'); + $save_button->press(); + $this->assertSame('Saving', $save_button->getText()); + } + + /** + * Awaits Quick Edit to be initiated for all instances of the given entity. + * + * @param string $entity_type_id + * The entity type ID. + * @param int $entity_id + * The entity ID. + */ + protected function awaitQuickEditForEntity($entity_type_id, $entity_id) { + $entity_selector = '[data-quickedit-entity-id="' . $entity_type_id . '/' . $entity_id . '"]'; + $condition = "document.querySelectorAll('" . $entity_selector . "').length === document.querySelectorAll('" . $entity_selector . " .quickedit').length"; + $this->assertJsCondition($condition, 10000); + } + + /** + * Awaits a particular field instance to reach a particular state. + * + * @param string $entity_type_id + * The entity type ID. + * @param int $entity_id + * The entity ID. + * @param int $entity_instance_id + * The entity instance ID. (Instance on the page.) + * @param string $field_name + * The field name. + * @param string $langcode + * The language code. + * @param string $awaited_state + * One of the possible field states. + */ + protected function awaitEntityInstanceFieldState($entity_type_id, $entity_id, $entity_instance_id, $field_name, $langcode, $awaited_state) { + $entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']'; + $logical_field_id = $entity_type_id . '/' . $entity_id . '/' . $field_name . '/' . $langcode; + $this->assertJsCondition("Drupal.quickedit.collections.entities.get('$entity_page_id').get('fields').findWhere({logicalFieldID: '$logical_field_id'}).get('state') === '$awaited_state';"); + } + + /** + * Asserts the state of the Quick Edit entity toolbar. + * + * @param string $expected_entity_label + * The expected label in the Quick Edit Entity Toolbar. + */ + protected function assertQuickEditEntityToolbar($expected_entity_label, $expected_field_label) { + $quickedit_entity_toolbar = $this->getSession()->getPage()->findById('quickedit-entity-toolbar'); + // We cannot use ->getText() because it also returns the text of all child + // nodes. We also cannot use XPath to select text node in Selenium. So we + // use JS expression to select only the text node. + $this->assertSame($expected_entity_label, $this->getSession()->evaluateScript("return window.jQuery('#quickedit-entity-toolbar .quickedit-toolbar-label').clone().children().remove().end().text();")); + if ($expected_field_label !== NULL) { + $field_label = $quickedit_entity_toolbar->find('css', '.quickedit-toolbar-label > .field'); + // Only try to find the text content of the element if it was actually + // found; otherwise use the returned value for assertion. This helps + // us find a more useful stack/error message from testbot instead of the + // trimmed partial exception stack. + if ($field_label) { + $field_label = $field_label->getText(); + } + $this->assertSame($expected_field_label, $field_label); + } + else { + $this->assertFalse($quickedit_entity_toolbar->find('css', '.quickedit-toolbar-label > .field')); + } + } + + /** + * Asserts all EntityModels (entity instances) on the page. + * + * @param array $expected_entity_states + * Must describe the expected state of all in-place editable entity + * instances on the page. + * + * @see Drupal.quickedit.EntityModel + */ + protected function assertEntityInstanceStates(array $expected_entity_states) { + $js_get_all_field_states_for_entity = <<assertSame($expected_entity_states, $this->getSession()->evaluateScript($js_get_all_field_states_for_entity)); + } + + /** + * Asserts all FieldModels for the given entity instance. + * + * @param string $entity_type_id + * The entity type ID. + * @param int $entity_id + * The entity ID. + * @param int $entity_instance_id + * The entity instance ID. (Instance on the page.) + * @param array $expected_field_states + * Must describe the expected state of all in-place editable fields of the + * given entity instance. + */ + protected function assertEntityInstanceFieldStates($entity_type_id, $entity_id, $entity_instance_id, array $expected_field_states) { + // Get all FieldModel states for the entity instance being asserted. This + // ensures that $expected_field_states must describe the state of all fields + // of the entity instance. + $entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']'; + $js_get_all_field_states_for_entity = <<assertEquals($expected_field_states, $this->getSession()->evaluateScript($js_get_all_field_states_for_entity)); + + // Assert that those fields also have the appropriate DOM decorations. + $expected_field_attributes = []; + foreach ($expected_field_states as $quickedit_field_id => $expected_field_state) { + $expected_field_attributes[$quickedit_field_id] = static::$expectedFieldStateAttributes[$expected_field_state]; + } + $this->assertEntityInstanceFieldMarkup($entity_type_id, $entity_id, $entity_instance_id, $expected_field_attributes); + } + + /** + * Asserts all in-place editable fields with markup expectations. + * + * @param string $entity_type_id + * The entity type ID. + * @param int $entity_id + * The entity ID. + * @param int $entity_instance_id + * The entity instance ID. (Instance on the page.) + * @param array $expected_field_attributes + * Must describe the expected markup attributes for all given in-place + * editable fields. + */ + protected function assertEntityInstanceFieldMarkup($entity_type_id, $entity_id, $entity_instance_id, array $expected_field_attributes) { + $entity_page_id = $entity_type_id . '/' . $entity_id . '[' . $entity_instance_id . ']'; + $expected_field_attributes_json = json_encode($expected_field_attributes); + $js_match_field_element_attributes = <<getSession()->evaluateScript($js_match_field_element_attributes); + foreach ($expected_field_attributes as $quickedit_field_id => $expectation) { + $this->assertSame(TRUE, $result[$quickedit_field_id], 'Field ' . $quickedit_field_id . ' did not match its expectation selector (' . $expectation . '), actual HTML: ' . $result[$quickedit_field_id]); + } + } + + /** + * Simulates typing in a 'plain_text' in-place editor. + * + * @param string $css_selector + * The CSS selector to find the DOM element (with the 'contenteditable=true' + * attribute set), to type in. + * @param string $text + * The text to type. + * + * @see \Drupal\quickedit\Plugin\InPlaceEditor\PlainTextEditor + */ + protected function typeInPlainTextEditor($css_selector, $text) { + $field = $this->getSession()->getPage()->find('css', $css_selector); + $field->setValue(Key::END . $text); + } + + /** + * Simulates typing in an input[type=text] inside a 'form' in-place editor. + * + * @param string $input_name + * The "name" attribute of the input[type=text] to type in. + * @param string $text + * The text to type. + * + * @see \Drupal\quickedit\Plugin\InPlaceEditor\FormEditor + */ + protected function typeInFormEditorTextInputField($input_name, $text) { + $input = $this->cssSelect('.quickedit-form-container > .quickedit-form[role="dialog"] form.quickedit-field-form input[type=text][name="' . $input_name . '"]')[0]; + $input->setValue($text); + $js_simulate_user_typing = << .quickedit-form[role="dialog"] form.quickedit-field-form input[name="$input_name"]'); + window.jQuery(el).trigger('formUpdated'); +}() +JS; + $this->getSession()->evaluateScript($js_simulate_user_typing); + } + +} From ba764ba0ac17f556d6f4f38252affa48e4f1fa01 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 20:01:22 +0100 Subject: [PATCH 03/39] Issue #2933413 by Graber, alexpott, joelpittet, chanderbhushan, jchand: Improve test coverage of using bulk actions when the view has an exposed form using AJAX --- .../FunctionalJavascript/ExposedFilterAJAXTest.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php b/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php index 974aa2da424f..dc6846876b61 100644 --- a/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php +++ b/core/modules/views/tests/src/FunctionalJavascript/ExposedFilterAJAXTest.php @@ -40,6 +40,7 @@ public function testExposedFiltering() { 'administer site configuration', 'access content', 'access content overview', + 'edit any page content', ]); $this->drupalLogin($user); @@ -71,6 +72,16 @@ public function testExposedFiltering() { $this->assertContains('Page Two', $html); $this->assertNotContains('Page One', $html); + // Submit bulk actions form to ensure that the previous AJAX submit does not + // break it. + $this->submitForm([ + 'action' => 'node_make_sticky_action', + 'node_bulk_form[0]' => TRUE, + ], t('Apply to selected items')); + + // Verify that the action was performed. + $this->assertSession()->pageTextContains('Make content sticky was applied to 1 item.'); + // Reset the form. $this->submitForm([], t('Reset')); $this->assertSession()->assertWaitOnAjaxRequest(); From e74d54b5b58bf283b8c8a40913cf47b88f823829 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 20:05:20 +0100 Subject: [PATCH 04/39] Issue #2960507 by alexpott: Remove call_user_func_array from OptionsRequestSubscriber --- .../Core/EventSubscriber/OptionsRequestSubscriber.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/lib/Drupal/Core/EventSubscriber/OptionsRequestSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/OptionsRequestSubscriber.php index 3abc55c53a64..ee22937dcaad 100644 --- a/core/lib/Drupal/Core/EventSubscriber/OptionsRequestSubscriber.php +++ b/core/lib/Drupal/Core/EventSubscriber/OptionsRequestSubscriber.php @@ -46,11 +46,11 @@ public function onRequest(GetResponseEvent $event) { // In case we don't have any routes, a 403 should be thrown by the normal // request handling. if (count($routes) > 0) { - $methods = array_map(function (Route $route) { - return $route->getMethods(); - }, $routes->all()); // Flatten and unique the available methods. - $methods = array_unique(call_user_func_array('array_merge', $methods)); + $methods = array_reduce($routes->all(), function ($methods, Route $route) { + return array_merge($methods, $route->getMethods()); + }, []); + $methods = array_unique($methods); $response = new Response('', 200, ['Allow' => implode(', ', $methods)]); $event->setResponse($response); } From 86c9b347fdfbc18cff3d8217a1a888fb2ed34ed0 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 22:40:40 +0100 Subject: [PATCH 05/39] Issue #2980405 by mbovan: Fix "view all unpublished content" documentation leftover --- .../src/Entity/Routing/EntityModerationRouteProvider.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/modules/content_moderation/src/Entity/Routing/EntityModerationRouteProvider.php b/core/modules/content_moderation/src/Entity/Routing/EntityModerationRouteProvider.php index ac6af35d3b4a..1349e1b33b92 100644 --- a/core/modules/content_moderation/src/Entity/Routing/EntityModerationRouteProvider.php +++ b/core/modules/content_moderation/src/Entity/Routing/EntityModerationRouteProvider.php @@ -81,7 +81,7 @@ protected function getLatestVersionRoute(EntityTypeInterface $entity_type) { '_title_callback' => '\Drupal\Core\Entity\Controller\EntityController::title', ]) // If the entity type is a node, unpublished content will be visible - // if the user has the "view all unpublished content" permission. + // if the user has the "view any unpublished content" permission. ->setRequirement('_entity_access', "{$entity_type_id}.view") ->setRequirement('_content_moderation_latest_version', 'TRUE') ->setOption('_content_moderation_entity_type', $entity_type_id) From a4560fd0571c5416a9bc05c8d673f98fcd614a31 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 22:43:41 +0100 Subject: [PATCH 06/39] Issue #2979930 by visshu007, paiabhay8: Wrap comments at 80 chars in demo_umami profile --- core/profiles/demo_umami/demo_umami.profile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/profiles/demo_umami/demo_umami.profile b/core/profiles/demo_umami/demo_umami.profile index 97ae45da0b5d..052f5b267266 100644 --- a/core/profiles/demo_umami/demo_umami.profile +++ b/core/profiles/demo_umami/demo_umami.profile @@ -31,8 +31,8 @@ function demo_umami_form_install_configure_submit($form, FormStateInterface $for */ function demo_umami_toolbar() { // Add a warning about using an experimental profile. - // @todo: This can be removed once a generic warning for experimental profiles has been introduced. - // @see https://www.drupal.org/project/drupal/issues/2934374 + // @todo This can be removed once a generic warning for experimental profiles + // has been introduced. https://www.drupal.org/project/drupal/issues/2934374 $items['experimental-profile-warning'] = [ '#weight' => 999, '#cache' => [ From fb05cb4bd38384365e19c5f9c1d047f6fa0b9d10 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 22:46:28 +0100 Subject: [PATCH 07/39] Issue #2930996 by alexpott, mtodor, chr.fritsch, Berdir: Config installer doens't install possible installable config --- .../Drupal/Core/Config/ConfigInstaller.php | 14 ++++++----- ...fig_test.dynamic.dependency_for_unmet2.yml | 7 ++++++ ...her_module_test_optional_entity_unmet2.yml | 11 ++++++++ .../src/Functional/ConfigOtherModuleTest.php | 25 ++++++++++++++++--- .../tests/src/Kernel/ViewsKernelTestBase.php | 2 ++ 5 files changed, 49 insertions(+), 10 deletions(-) create mode 100644 core/modules/config/tests/config_install_dependency_test/config/optional/config_test.dynamic.dependency_for_unmet2.yml create mode 100644 core/modules/config/tests/config_other_module_config_test/config/optional/config_test.dynamic.other_module_test_optional_entity_unmet2.yml diff --git a/core/lib/Drupal/Core/Config/ConfigInstaller.php b/core/lib/Drupal/Core/Config/ConfigInstaller.php index 24d735b99d59..b1ea748e7b2c 100644 --- a/core/lib/Drupal/Core/Config/ConfigInstaller.php +++ b/core/lib/Drupal/Core/Config/ConfigInstaller.php @@ -4,7 +4,6 @@ use Drupal\Component\Utility\Crypt; use Drupal\Core\Config\Entity\ConfigDependencyManager; -use Drupal\Core\Config\Entity\ConfigEntityDependency; use Symfony\Component\EventDispatcher\EventDispatcherInterface; class ConfigInstaller implements ConfigInstallerInterface { @@ -204,16 +203,19 @@ public function installOptionalConfig(StorageInterface $storage = NULL, $depende $dependency_manager = new ConfigDependencyManager(); $dependency_manager->setData($config_to_create); $config_to_create = array_merge(array_flip($dependency_manager->sortAll()), $config_to_create); + if (!empty($dependency)) { + // In order to work out dependencies we need the full config graph. + $dependency_manager->setData($this->getActiveStorages()->readMultiple($existing_config) + $config_to_create); + $dependencies = $dependency_manager->getDependentEntities(key($dependency), reset($dependency)); + } foreach ($config_to_create as $config_name => $data) { // Remove configuration where its dependencies cannot be met. $remove = !$this->validateDependencies($config_name, $data, $enabled_extensions, $all_config); - // If $dependency is defined, remove configuration that does not have a - // matching dependency. + // Remove configuration that is not dependent on $dependency, if it is + // defined. if (!$remove && !empty($dependency)) { - // Create a light weight dependency object to check dependencies. - $config_entity = new ConfigEntityDependency($config_name, $data); - $remove = !$config_entity->hasDependency(key($dependency), reset($dependency)); + $remove = !isset($dependencies[$config_name]); } if ($remove) { diff --git a/core/modules/config/tests/config_install_dependency_test/config/optional/config_test.dynamic.dependency_for_unmet2.yml b/core/modules/config/tests/config_install_dependency_test/config/optional/config_test.dynamic.dependency_for_unmet2.yml new file mode 100644 index 000000000000..89d4dbb17cdb --- /dev/null +++ b/core/modules/config/tests/config_install_dependency_test/config/optional/config_test.dynamic.dependency_for_unmet2.yml @@ -0,0 +1,7 @@ +id: dependency_for_unmet2 +label: 'Other module test to test optional config installation' +weight: 0 +style: '' +status: true +langcode: en +protected_property: Default diff --git a/core/modules/config/tests/config_other_module_config_test/config/optional/config_test.dynamic.other_module_test_optional_entity_unmet2.yml b/core/modules/config/tests/config_other_module_config_test/config/optional/config_test.dynamic.other_module_test_optional_entity_unmet2.yml new file mode 100644 index 000000000000..9a9d4a369865 --- /dev/null +++ b/core/modules/config/tests/config_other_module_config_test/config/optional/config_test.dynamic.other_module_test_optional_entity_unmet2.yml @@ -0,0 +1,11 @@ +id: other_module_test_optional_entity_unmet2 +label: 'Other module test to test optional config installation' +weight: 0 +style: '' +status: true +langcode: en +protected_property: Default +dependencies: + enforced: + config: + - config_test.dynamic.dependency_for_unmet2 diff --git a/core/modules/config/tests/src/Functional/ConfigOtherModuleTest.php b/core/modules/config/tests/src/Functional/ConfigOtherModuleTest.php index a99676072b57..e1bc859b2992 100644 --- a/core/modules/config/tests/src/Functional/ConfigOtherModuleTest.php +++ b/core/modules/config/tests/src/Functional/ConfigOtherModuleTest.php @@ -62,12 +62,29 @@ public function testInstallOtherModuleFirst() { $this->assertNull(entity_load('config_test', 'other_module_test_unmet', TRUE), 'The optional configuration config_test.dynamic.other_module_test_unmet whose dependencies are not met is not created.'); $this->assertNull(entity_load('config_test', 'other_module_test_optional_entity_unmet', TRUE), 'The optional configuration config_test.dynamic.other_module_test_optional_entity_unmet whose dependencies are not met is not created.'); $this->installModule('config_test_language'); + $this->assertNull(entity_load('config_test', 'other_module_test_optional_entity_unmet2', TRUE), 'The optional configuration config_test.dynamic.other_module_test_optional_entity_unmet2 whose dependencies are not met is not created.'); $this->installModule('config_install_dependency_test'); $this->assertTrue(entity_load('config_test', 'other_module_test_unmet', TRUE), 'The optional configuration config_test.dynamic.other_module_test_unmet whose dependencies are met is now created.'); - // Although the following configuration entity's are now met it is not - // installed because it does not have a direct dependency on the - // config_install_dependency_test module. - $this->assertNull(entity_load('config_test', 'other_module_test_optional_entity_unmet', TRUE), 'The optional configuration config_test.dynamic.other_module_test_optional_entity_unmet whose dependencies are met is not created.'); + // The following configuration entity's dependencies are now met. It is + // indirectly dependent on the config_install_dependency_test module because + // it has a dependency on the config_test.dynamic.dependency_for_unmet2 + // configuration provided by that module and, therefore, should be created. + $this->assertTrue(entity_load('config_test', 'other_module_test_optional_entity_unmet2', TRUE), 'The optional configuration config_test.dynamic.other_module_test_optional_entity_unmet2 whose dependencies are met is now created.'); + + // The following configuration entity's dependencies are now met even though + // it has no direct dependency on the module. It is indirectly dependent on + // the config_install_dependency_test module because it has a dependency on + // the config_test.dynamic.other_module_test_unmet configuration that is + // dependent on the config_install_dependency_test module and, therefore, + // should be created. + $entity = entity_load('config_test', 'other_module_test_optional_entity_unmet', TRUE); + $this->assertTrue($entity, 'The optional configuration config_test.dynamic.other_module_test_optional_entity_unmet whose dependencies are met is created.'); + $entity->delete(); + + // Install another module to ensure the configuration just deleted is not + // recreated. + $this->installModule('config'); + $this->assertFalse(entity_load('config_test', 'other_module_test_optional_entity_unmet', TRUE), 'The optional configuration config_test.dynamic.other_module_test_optional_entity_unmet whose dependencies are met is not installed when an unrelated module is installed.'); } /** diff --git a/core/modules/views/tests/src/Kernel/ViewsKernelTestBase.php b/core/modules/views/tests/src/Kernel/ViewsKernelTestBase.php index af69ba1873db..be9920eb31db 100644 --- a/core/modules/views/tests/src/Kernel/ViewsKernelTestBase.php +++ b/core/modules/views/tests/src/Kernel/ViewsKernelTestBase.php @@ -7,6 +7,7 @@ use Drupal\KernelTests\KernelTestBase; use Drupal\views\Tests\ViewResultAssertionTrait; use Drupal\views\Tests\ViewTestData; +use Drupal\views\ViewsData; /** * Defines a base class for Views kernel testing. @@ -65,6 +66,7 @@ protected function setUpFixtures() { // Define the schema and views data variable before enabling the test module. $state->set('views_test_data_schema', $this->schemaDefinition()); $state->set('views_test_data_views_data', $this->viewsData()); + $this->container->get('views.views_data')->clear(); $this->installConfig(['views', 'views_test_config', 'views_test_data']); foreach ($this->schemaDefinition() as $table => $schema) { From aae672cc280c4bacb510f9944a1bf39966445412 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 22:51:20 +0100 Subject: [PATCH 08/39] Issue #2940203 by almaudoh, dawehner: Use dedicated Exception classes for extension system --- core/includes/bootstrap.inc | 3 ++- .../Exception/UninstalledExtensionException.php | 8 ++++++++ .../Exception/UnknownExtensionException.php | 8 ++++++++ .../lib/Drupal/Core/Extension/ExtensionList.php | 17 +++++++++-------- .../lib/Drupal/Core/Extension/ModuleHandler.php | 3 ++- .../Core/Extension/ModuleHandlerInterface.php | 2 +- core/lib/Drupal/Core/Extension/ThemeHandler.php | 8 +++++--- .../Core/Extension/ThemeHandlerInterface.php | 9 ++++++--- .../Drupal/Core/Extension/ThemeInstaller.php | 5 +++-- .../Core/Extension/ThemeInstallerInterface.php | 8 +++++++- core/modules/system/system.module | 3 ++- .../Core/Theme/ThemeInstallerTest.php | 13 +++++++------ .../Tests/Core/Extension/ExtensionListTest.php | 5 +++-- .../Tests/Core/Extension/ModuleHandlerTest.php | 3 ++- 14 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 core/lib/Drupal/Core/Extension/Exception/UninstalledExtensionException.php create mode 100644 core/lib/Drupal/Core/Extension/Exception/UnknownExtensionException.php diff --git a/core/includes/bootstrap.inc b/core/includes/bootstrap.inc index 4d28eaa03615..c2aeb55e0c52 100644 --- a/core/includes/bootstrap.inc +++ b/core/includes/bootstrap.inc @@ -10,6 +10,7 @@ use Drupal\Component\Utility\Html; use Drupal\Component\Render\FormattableMarkup; use Drupal\Component\Utility\Unicode; use Drupal\Core\Config\BootstrapConfigStorageFactory; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Logger\RfcLogLevel; use Drupal\Core\Test\TestDatabase; use Drupal\Core\Session\AccountInterface; @@ -246,7 +247,7 @@ function drupal_get_filename($type, $name, $filename = NULL) { try { return $extension_list->getPathname($name); } - catch (\InvalidArgumentException $e) { + catch (UnknownExtensionException $e) { // Catch the exception. This will result in triggering an error. } } diff --git a/core/lib/Drupal/Core/Extension/Exception/UninstalledExtensionException.php b/core/lib/Drupal/Core/Extension/Exception/UninstalledExtensionException.php new file mode 100644 index 000000000000..f0503f1980b5 --- /dev/null +++ b/core/lib/Drupal/Core/Extension/Exception/UninstalledExtensionException.php @@ -0,0 +1,8 @@ +type} $extension_name does not exist."); + throw new UnknownExtensionException("The {$this->type} $extension_name does not exist."); } /** @@ -334,7 +335,7 @@ protected function doList() { * @return mixed[] * An associative array of extension information. * - * @throws \InvalidArgumentException + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException * If there is no extension with the supplied name. */ public function getExtensionInfo($extension_name) { @@ -342,7 +343,7 @@ public function getExtensionInfo($extension_name) { if (isset($all_info[$extension_name])) { return $all_info[$extension_name]; } - throw new \InvalidArgumentException("The {$this->type} $extension_name does not exist or is not installed."); + throw new UnknownExtensionException("The {$this->type} $extension_name does not exist or is not installed."); } /** @@ -505,7 +506,7 @@ public function setPathname($extension_name, $pathname) { * The drupal-root relative filename and path of the requested extension's * .info.yml file. * - * @throws \InvalidArgumentException + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException * If there is no extension with the supplied machine name. */ public function getPathname($extension_name) { @@ -518,7 +519,7 @@ public function getPathname($extension_name) { elseif (($path_names = $this->getPathnames()) && isset($path_names[$extension_name])) { return $path_names[$extension_name]; } - throw new \InvalidArgumentException("The {$this->type} $extension_name does not exist."); + throw new UnknownExtensionException("The {$this->type} $extension_name does not exist."); } /** @@ -533,7 +534,7 @@ public function getPathname($extension_name) { * @return string * The Drupal-root-relative path to the specified extension. * - * @throws \InvalidArgumentException + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException * If there is no extension with the supplied name. */ public function getPath($extension_name) { diff --git a/core/lib/Drupal/Core/Extension/ModuleHandler.php b/core/lib/Drupal/Core/Extension/ModuleHandler.php index a3555cd98dc6..8d43a857dd4c 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandler.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandler.php @@ -5,6 +5,7 @@ use Drupal\Component\Graph\Graph; use Drupal\Component\Utility\NestedArray; use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\Exception\UnknownExtensionException; /** * Class that manages modules in a Drupal installation. @@ -172,7 +173,7 @@ public function getModule($name) { if (isset($this->moduleList[$name])) { return $this->moduleList[$name]; } - throw new \InvalidArgumentException(sprintf('The module %s does not exist.', $name)); + throw new UnknownExtensionException(sprintf('The module %s does not exist.', $name)); } /** diff --git a/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php b/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php index f1097e388104..abb4edde32b1 100644 --- a/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php +++ b/core/lib/Drupal/Core/Extension/ModuleHandlerInterface.php @@ -61,7 +61,7 @@ public function getModuleList(); * @return \Drupal\Core\Extension\Extension * An extension object. * - * @throws \InvalidArgumentException + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException * Thrown when the requested module does not exist. */ public function getModule($name); diff --git a/core/lib/Drupal/Core/Extension/ThemeHandler.php b/core/lib/Drupal/Core/Extension/ThemeHandler.php index d54ff1fa1a14..4258fda9fa4f 100644 --- a/core/lib/Drupal/Core/Extension/ThemeHandler.php +++ b/core/lib/Drupal/Core/Extension/ThemeHandler.php @@ -3,6 +3,8 @@ namespace Drupal\Core\Extension; use Drupal\Core\Config\ConfigFactoryInterface; +use Drupal\Core\Extension\Exception\UninstalledExtensionException; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\State\StateInterface; /** @@ -147,7 +149,7 @@ public function getDefault() { public function setDefault($name) { $list = $this->listInfo(); if (!isset($list[$name])) { - throw new \InvalidArgumentException("$name theme is not installed."); + throw new UninstalledExtensionException("$name theme is not installed."); } $this->configFactory->getEditable('system.theme') ->set('default', $name) @@ -437,7 +439,7 @@ protected function getExtensionDiscovery() { public function getName($theme) { $themes = $this->listInfo(); if (!isset($themes[$theme])) { - throw new \InvalidArgumentException("Requested the name of a non-existing theme $theme"); + throw new UnknownExtensionException("Requested the name of a non-existing theme $theme"); } return $themes[$theme]->info['name']; } @@ -486,7 +488,7 @@ public function getTheme($name) { if (isset($themes[$name])) { return $themes[$name]; } - throw new \InvalidArgumentException(sprintf('The theme %s does not exist.', $name)); + throw new UnknownExtensionException(sprintf('The theme %s does not exist.', $name)); } /** diff --git a/core/lib/Drupal/Core/Extension/ThemeHandlerInterface.php b/core/lib/Drupal/Core/Extension/ThemeHandlerInterface.php index 00433f0b9664..56d9e9a8a86c 100644 --- a/core/lib/Drupal/Core/Extension/ThemeHandlerInterface.php +++ b/core/lib/Drupal/Core/Extension/ThemeHandlerInterface.php @@ -39,8 +39,8 @@ public function install(array $theme_list, $install_dependencies = TRUE); * @param array $theme_list * The themes to uninstall. * - * @throws \InvalidArgumentException - * Thrown when you uninstall an not installed theme. + * @throws \Drupal\Core\Extension\Exception\UninstalledExtensionException + * Thrown when you try to uninstall a theme that wasn't installed. * * @see hook_themes_uninstalled() * @@ -146,6 +146,9 @@ public function getBaseThemes(array $themes, $theme); * * @return string * Returns the human readable name of the theme. + * + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException + * When the specified theme does not exist. */ public function getName($theme); @@ -206,7 +209,7 @@ public function themeExists($theme); * @return \Drupal\Core\Extension\Extension * An extension object. * - * @throws \InvalidArgumentException + * @throws \Drupal\Core\Extension\Extension\UnknownExtensionException * Thrown when the requested theme does not exist. */ public function getTheme($name); diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php index 43a5469e3f4f..2d6567fa1886 100644 --- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php +++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\ConfigFactoryInterface; use Drupal\Core\Config\ConfigInstallerInterface; use Drupal\Core\Config\ConfigManagerInterface; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Routing\RouteBuilderInterface; use Drupal\Core\State\StateInterface; use Psr\Log\LoggerInterface; @@ -111,7 +112,7 @@ public function install(array $theme_list, $install_dependencies = TRUE) { if ($missing = array_diff_key($theme_list, $theme_data)) { // One or more of the given themes doesn't exist. - throw new \InvalidArgumentException('Unknown themes: ' . implode(', ', $missing) . '.'); + throw new UnknownExtensionException('Unknown themes: ' . implode(', ', $missing) . '.'); } // Only process themes that are not installed currently. @@ -221,7 +222,7 @@ public function uninstall(array $theme_list) { $list = $this->themeHandler->listInfo(); foreach ($theme_list as $key) { if (!isset($list[$key])) { - throw new \InvalidArgumentException("Unknown theme: $key."); + throw new UnknownExtensionException("Unknown theme: $key."); } if ($key === $theme_config->get('default')) { throw new \InvalidArgumentException("The current default theme $key cannot be uninstalled."); diff --git a/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php b/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php index ad80762ed1cb..ae79b505ea18 100644 --- a/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php +++ b/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php @@ -22,6 +22,9 @@ interface ThemeInstallerInterface { * * @throws \Drupal\Core\Extension\ExtensionNameLengthException * Thrown when the theme name is to long. + * + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException + * Thrown when the theme does not exist. */ public function install(array $theme_list, $install_dependencies = TRUE); @@ -34,8 +37,11 @@ public function install(array $theme_list, $install_dependencies = TRUE); * @param array $theme_list * The themes to uninstall. * + * @throws \Drupal\Core\Extension\Exception\UnknownExtensionException + * Thrown when trying to uninstall a theme that was not installed. + * * @throws \InvalidArgumentException - * Thrown when you uninstall an not installed theme. + * Thrown when trying to uninstall the default theme or the admin theme. * * @see hook_themes_uninstalled() */ diff --git a/core/modules/system/system.module b/core/modules/system/system.module index d6bc508dab64..e82e1ffd4824 100644 --- a/core/modules/system/system.module +++ b/core/modules/system/system.module @@ -9,6 +9,7 @@ use Drupal\Component\Render\PlainTextOutput; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AttachedAssetsInterface; use Drupal\Core\Cache\Cache; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Queue\QueueGarbageCollectionInterface; use Drupal\Core\Database\Query\AlterableInterface; use Drupal\Core\Extension\Extension; @@ -972,7 +973,7 @@ function system_get_info($type, $name = NULL) { try { return $module_list->getExtensionInfo($name); } - catch (\InvalidArgumentException $e) { + catch (UnknownExtensionException $e) { return []; } } diff --git a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php index 61f647528d07..dc606def6658 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php @@ -4,6 +4,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Extension\ExtensionNameLengthException; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\KernelTests\KernelTestBase; /** @@ -110,11 +111,11 @@ public function testInstallNonExisting() { $this->assertFalse(array_keys($themes)); try { - $message = 'ThemeHandler::install() throws InvalidArgumentException upon installing a non-existing theme.'; + $message = 'ThemeHandler::install() throws UnknownExtensionException upon installing a non-existing theme.'; $this->themeInstaller()->install([$name]); $this->fail($message); } - catch (\InvalidArgumentException $e) { + catch (UnknownExtensionException $e) { $this->pass(get_class($e) . ': ' . $e->getMessage()); } @@ -247,11 +248,11 @@ public function testUninstallNonExisting() { $this->assertFalse(array_keys($themes)); try { - $message = 'ThemeHandler::uninstall() throws InvalidArgumentException upon uninstalling a non-existing theme.'; + $message = 'ThemeHandler::uninstall() throws UnknownExtensionException upon uninstalling a non-existing theme.'; $this->themeInstaller()->uninstall([$name]); $this->fail($message); } - catch (\InvalidArgumentException $e) { + catch (UnknownExtensionException $e) { $this->pass(get_class($e) . ': ' . $e->getMessage()); } @@ -291,11 +292,11 @@ public function testUninstallNotInstalled() { $name = 'test_basetheme'; try { - $message = 'ThemeHandler::uninstall() throws InvalidArgumentException upon uninstalling a theme that is not installed.'; + $message = 'ThemeHandler::uninstall() throws UnknownExtensionException upon uninstalling a theme that is not installed.'; $this->themeInstaller()->uninstall([$name]); $this->fail($message); } - catch (\InvalidArgumentException $e) { + catch (UnknownExtensionException $e) { $this->pass(get_class($e) . ': ' . $e->getMessage()); } } diff --git a/core/tests/Drupal/Tests/Core/Extension/ExtensionListTest.php b/core/tests/Drupal/Tests/Core/Extension/ExtensionListTest.php index 993bf759ffba..7f73fe27ea7f 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ExtensionListTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ExtensionListTest.php @@ -9,6 +9,7 @@ use Drupal\Core\Extension\ExtensionList; use Drupal\Core\Extension\InfoParserInterface; use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\State\StateInterface; use Drupal\Tests\UnitTestCase; use org\bovigo\vfs\vfsStream; @@ -31,7 +32,7 @@ public function testGetNameWithNonExistingExtension() { $extension_discovery->scan('test_extension')->willReturn([]); $test_extension_list->setExtensionDiscovery($extension_discovery->reveal()); - $this->setExpectedException(\InvalidArgumentException::class); + $this->setExpectedException(UnknownExtensionException::class); $test_extension_list->getName('test_name'); } @@ -55,7 +56,7 @@ public function testGetWithNonExistingExtension() { $extension_discovery->scan('test_extension')->willReturn([]); $test_extension_list->setExtensionDiscovery($extension_discovery->reveal()); - $this->setExpectedException(\InvalidArgumentException::class); + $this->setExpectedException(UnknownExtensionException::class); $test_extension_list->get('test_name'); } diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php index 820c6bd47ccc..3adaf13507a3 100644 --- a/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Extension/ModuleHandlerTest.php @@ -5,6 +5,7 @@ use Drupal\Core\Cache\CacheBackendInterface; use Drupal\Core\Extension\Extension; use Drupal\Core\Extension\ModuleHandler; +use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Tests\UnitTestCase; /** @@ -164,7 +165,7 @@ public function testGetModuleWithExistingModule() { * @covers ::getModule */ public function testGetModuleWithNonExistingModule() { - $this->setExpectedException(\InvalidArgumentException::class); + $this->setExpectedException(UnknownExtensionException::class); $this->getModuleHandler()->getModule('claire_alice_watch_my_little_pony_module_that_does_not_exist'); } From bb6ea924e8f4d9a2d1e4f478212e6c12566e9d74 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 22:59:09 +0100 Subject: [PATCH 09/39] Issue #2875679 by mondrake, daffie: BasicSyntaxTest::testConcatFields fails with contrib driver --- .../Core/Database/BasicSyntaxTest.php | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php index 17f8ebb92c94..837b8ea0364d 100644 --- a/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php +++ b/core/tests/Drupal/KernelTests/Core/Database/BasicSyntaxTest.php @@ -29,15 +29,22 @@ public function testConcatLiterals() { /** * Tests string concatenation with field values. + * + * We use 'job' and 'age' fields from the {test} table. Using the 'name' field + * for concatenation causes issues with custom or contrib database drivers, + * since its type 'varchar_ascii' may lead to using field-level collations not + * compatible with the other fields. */ public function testConcatFields() { - $result = db_query('SELECT CONCAT(:a1, CONCAT(name, CONCAT(:a2, CONCAT(age, :a3)))) FROM {test} WHERE age = :age', [ - ':a1' => 'The age of ', - ':a2' => ' is ', - ':a3' => '.', - ':age' => 25, - ]); - $this->assertIdentical($result->fetchField(), 'The age of John is 25.', 'Field CONCAT works.'); + $result = $this->connection->query( + 'SELECT CONCAT(:a1, CONCAT(job, CONCAT(:a2, CONCAT(age, :a3)))) FROM {test} WHERE age = :age', [ + ':a1' => 'The age of ', + ':a2' => ' is ', + ':a3' => '.', + ':age' => 25, + ] + ); + $this->assertSame('The age of Singer is 25.', $result->fetchField(), 'Field CONCAT works.'); } /** From d95a6b00533fd5529b57cc9e1a6fd52c00791683 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 23:01:18 +0100 Subject: [PATCH 10/39] Issue #2977250 by alexpott: Media subsystem 99% of time uses thumbnail URI not URL --- core/modules/media/src/MediaListBuilder.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/modules/media/src/MediaListBuilder.php b/core/modules/media/src/MediaListBuilder.php index a0f1f02b21f5..237129bbb02d 100644 --- a/core/modules/media/src/MediaListBuilder.php +++ b/core/modules/media/src/MediaListBuilder.php @@ -115,11 +115,11 @@ public function buildRow(EntityInterface $entity) { /** @var \Drupal\media\MediaInterface $entity */ if ($this->thumbnailStyleExists) { $row['thumbnail'] = []; - if ($thumbnail_url = $entity->getSource()->getMetadata($entity, 'thumbnail_uri')) { + if ($thumbnail_uri = $entity->getSource()->getMetadata($entity, 'thumbnail_uri')) { $row['thumbnail']['data'] = [ '#theme' => 'image_style', '#style_name' => 'thumbnail', - '#uri' => $thumbnail_url, + '#uri' => $thumbnail_uri, '#height' => 50, ]; } From 44986fefc25ef4e6eae770119393d6dc74fedf52 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Thu, 21 Jun 2018 23:24:05 +0100 Subject: [PATCH 11/39] Issue #2973509 by claudiu.cristea, tahirmus, alexpott: Image media source uses FileSystem class instead of FileSystemInterface for type hinting --- .../file/src/Plugin/rest/resource/FileUploadResource.php | 8 ++++---- core/modules/media/src/Plugin/media/Source/Image.php | 8 ++++---- core/tests/Drupal/Tests/Core/File/FileSystemTest.php | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php index e9cbb574996b..44600369df67 100644 --- a/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php +++ b/core/modules/file/src/Plugin/rest/resource/FileUploadResource.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\Config; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\FieldDefinitionInterface; +use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Lock\LockBackendInterface; use Drupal\Core\Session\AccountInterface; use Drupal\Core\Utility\Token; @@ -15,7 +16,6 @@ use Drupal\rest\Plugin\ResourceBase; use Drupal\Component\Render\PlainTextOutput; use Drupal\Core\Entity\EntityFieldManagerInterface; -use Drupal\Core\File\FileSystem; use Drupal\file\Entity\File; use Drupal\rest\Plugin\rest\resource\EntityResourceValidationTrait; use Drupal\rest\RequestHandler; @@ -73,7 +73,7 @@ class FileUploadResource extends ResourceBase { /** * The file system service. * - * @var \Drupal\Core\File\FileSystem + * @var \Drupal\Core\File\FileSystemInterface */ protected $fileSystem; @@ -137,7 +137,7 @@ class FileUploadResource extends ResourceBase { * The available serialization formats. * @param \Psr\Log\LoggerInterface $logger * A logger instance. - * @param \Drupal\Core\File\FileSystem $file_system + * @param \Drupal\Core\File\FileSystemInterface $file_system * The file system service. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager. @@ -154,7 +154,7 @@ class FileUploadResource extends ResourceBase { * @param \Drupal\Core\Config\Config $system_file_config * The system file configuration. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, $serializer_formats, LoggerInterface $logger, FileSystem $file_system, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, AccountInterface $current_user, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, Config $system_file_config) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, $serializer_formats, LoggerInterface $logger, FileSystemInterface $file_system, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, AccountInterface $current_user, MimeTypeGuesserInterface $mime_type_guesser, Token $token, LockBackendInterface $lock, Config $system_file_config) { parent::__construct($configuration, $plugin_id, $plugin_definition, $serializer_formats, $logger); $this->fileSystem = $file_system; $this->entityTypeManager = $entity_type_manager; diff --git a/core/modules/media/src/Plugin/media/Source/Image.php b/core/modules/media/src/Plugin/media/Source/Image.php index 46f6782c742d..a83a5144d24b 100644 --- a/core/modules/media/src/Plugin/media/Source/Image.php +++ b/core/modules/media/src/Plugin/media/Source/Image.php @@ -6,7 +6,7 @@ use Drupal\Core\Entity\EntityFieldManagerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Field\FieldTypePluginManagerInterface; -use Drupal\Core\File\FileSystem; +use Drupal\Core\File\FileSystemInterface; use Drupal\Core\Image\ImageFactory; use Drupal\media\MediaInterface; use Drupal\media\MediaTypeInterface; @@ -51,7 +51,7 @@ class Image extends File { /** * The file system service. * - * @var \Drupal\Core\File\FileSystem + * @var \Drupal\Core\File\FileSystemInterface */ protected $fileSystem; @@ -74,10 +74,10 @@ class Image extends File { * The config factory service. * @param \Drupal\Core\Image\ImageFactory $image_factory * The image factory. - * @param \Drupal\Core\File\FileSystem $file_system + * @param \Drupal\Core\File\FileSystemInterface $file_system * The file system service. */ - public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, FieldTypePluginManagerInterface $field_type_manager, ConfigFactoryInterface $config_factory, ImageFactory $image_factory, FileSystem $file_system) { + public function __construct(array $configuration, $plugin_id, $plugin_definition, EntityTypeManagerInterface $entity_type_manager, EntityFieldManagerInterface $entity_field_manager, FieldTypePluginManagerInterface $field_type_manager, ConfigFactoryInterface $config_factory, ImageFactory $image_factory, FileSystemInterface $file_system) { parent::__construct($configuration, $plugin_id, $plugin_definition, $entity_type_manager, $entity_field_manager, $field_type_manager, $config_factory); $this->imageFactory = $image_factory; diff --git a/core/tests/Drupal/Tests/Core/File/FileSystemTest.php b/core/tests/Drupal/Tests/Core/File/FileSystemTest.php index f0f9f3bec071..247e9f1b05c9 100644 --- a/core/tests/Drupal/Tests/Core/File/FileSystemTest.php +++ b/core/tests/Drupal/Tests/Core/File/FileSystemTest.php @@ -15,7 +15,7 @@ class FileSystemTest extends UnitTestCase { /** - * @var \Drupal\Core\File\FileSystem + * @var \Drupal\Core\File\FileSystemInterface */ protected $fileSystem; From 716f73dde1b3ef64bc28ea113934174185057776 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Fri, 22 Jun 2018 13:18:43 +0100 Subject: [PATCH 12/39] Issue #2968875 by timmillwood, amateescu: Bring back the query parameter workspace negotiator --- .../QueryParameterWorkspaceNegotiator.php | 33 +++++++++++++++++++ .../src/Functional/WorkspaceSwitcherTest.php | 28 ++++++++++++++-- core/modules/workspace/workspace.services.yml | 5 +++ 3 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 core/modules/workspace/src/Negotiator/QueryParameterWorkspaceNegotiator.php diff --git a/core/modules/workspace/src/Negotiator/QueryParameterWorkspaceNegotiator.php b/core/modules/workspace/src/Negotiator/QueryParameterWorkspaceNegotiator.php new file mode 100644 index 000000000000..0797035a433d --- /dev/null +++ b/core/modules/workspace/src/Negotiator/QueryParameterWorkspaceNegotiator.php @@ -0,0 +1,33 @@ +query->get('workspace')) && parent::applies($request); + } + + /** + * {@inheritdoc} + */ + public function getActiveWorkspace(Request $request) { + $workspace_id = $request->query->get('workspace'); + + if ($workspace_id && ($workspace = $this->workspaceStorage->load($workspace_id))) { + $this->setActiveWorkspace($workspace); + return $workspace; + } + + return NULL; + } + +} diff --git a/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php index ede23825a8b7..22a51b33c2d7 100644 --- a/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php +++ b/core/modules/workspace/tests/src/Functional/WorkspaceSwitcherTest.php @@ -19,9 +19,11 @@ class WorkspaceSwitcherTest extends BrowserTestBase { public static $modules = ['block', 'workspace']; /** - * Test switching workspace via the switcher block and admin page. + * {@inheritdoc} */ - public function testSwitchingWorkspaces() { + protected function setUp() { + parent::setUp(); + $permissions = [ 'create workspace', 'edit own workspace', @@ -33,7 +35,12 @@ public function testSwitchingWorkspaces() { $mayer = $this->drupalCreateUser($permissions); $this->drupalLogin($mayer); + } + /** + * Test switching workspace via the switcher block and admin page. + */ + public function testSwitchingWorkspaces() { $vultures = $this->createWorkspaceThroughUi('Vultures', 'vultures'); $this->switchToWorkspace($vultures); @@ -48,4 +55,21 @@ public function testSwitchingWorkspaces() { $page->findLink($gravity->label()); } + /** + * Test switching workspace via a query parameter. + */ + public function testQueryParameterNegotiator() { + $web_assert = $this->assertSession(); + // Initially the default workspace should be active. + $web_assert->elementContains('css', '.block-workspace-switcher', 'Live'); + + // When adding a query parameter the workspace will be switched. + $this->drupalGet('', ['query' => ['workspace' => 'stage']]); + $web_assert->elementContains('css', '.block-workspace-switcher', 'Stage'); + + // The workspace switching via query parameter should persist. + $this->drupalGet(''); + $web_assert->elementContains('css', '.block-workspace-switcher', 'Stage'); + } + } diff --git a/core/modules/workspace/workspace.services.yml b/core/modules/workspace/workspace.services.yml index 13d67aee5b6a..cd2b9ea51de0 100644 --- a/core/modules/workspace/workspace.services.yml +++ b/core/modules/workspace/workspace.services.yml @@ -15,6 +15,11 @@ services: workspace.negotiator.session: class: Drupal\workspace\Negotiator\SessionWorkspaceNegotiator arguments: ['@current_user', '@session', '@entity_type.manager'] + tags: + - { name: workspace_negotiator, priority: 50 } + workspace.negotiator.query_parameter: + class: Drupal\workspace\Negotiator\QueryParameterWorkspaceNegotiator + parent: workspace.negotiator.session tags: - { name: workspace_negotiator, priority: 100 } cache_context.workspace: From 252f7ffcf2bbbf5bfbd23894a63e44ae9f9d7e54 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Fri, 22 Jun 2018 13:33:12 +0100 Subject: [PATCH 13/39] Issue #2652850 by tstoeckler, mohit_aghera, idebr, hchonov, mbovan, rodrigoaguilera, jwilson3, pguillard, espurnes, dravenk, ainarend, handkerchief, alexpott: Title for details form elements is not set as '#markup' and it will be escaped, but all other form elements use '#markup' and are not escaped --- core/includes/form.inc | 5 ++ core/includes/theme.inc | 5 ++ .../src/Functional/NodeTranslationUITest.php | 24 +++++++++ .../modules/form_test/form_test.info.yml | 3 ++ .../form_test/src/Form/FormTestLabelForm.php | 49 +++++++++++++++++++ .../Functional/Form/ElementsLabelsTest.php | 12 +++++ 6 files changed, 98 insertions(+) diff --git a/core/includes/form.inc b/core/includes/form.inc index d248b7b5a9a9..dacae875bdeb 100644 --- a/core/includes/form.inc +++ b/core/includes/form.inc @@ -251,6 +251,11 @@ function template_preprocess_details(&$variables) { $variables['summary_attributes']['aria-pressed'] = $variables['summary_attributes']['aria-expanded']; } $variables['title'] = (!empty($element['#title'])) ? $element['#title'] : ''; + // If the element title is a string, wrap it a render array so that markup + // will not be escaped (but XSS-filtered). + if (is_string($variables['title']) && $variables['title'] !== '') { + $variables['title'] = ['#markup' => $variables['title']]; + } $variables['description'] = (!empty($element['#description'])) ? $element['#description'] : ''; $variables['children'] = (isset($element['#children'])) ? $element['#children'] : ''; $variables['value'] = (isset($element['#value'])) ? $element['#value'] : ''; diff --git a/core/includes/theme.inc b/core/includes/theme.inc index 30e5aafc6c71..5b9db30831f7 100644 --- a/core/includes/theme.inc +++ b/core/includes/theme.inc @@ -570,6 +570,11 @@ function template_preprocess_datetime_wrapper(&$variables) { if (!empty($element['#title'])) { $variables['title'] = $element['#title']; + // If the element title is a string, wrap it a render array so that markup + // will not be escaped (but XSS-filtered). + if (is_string($variables['title']) && $variables['title'] !== '') { + $variables['title'] = ['#markup' => $variables['title']]; + } } // Suppress error messages. diff --git a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php index 46516fe74aad..513c074932e3 100644 --- a/core/modules/node/tests/src/Functional/NodeTranslationUITest.php +++ b/core/modules/node/tests/src/Functional/NodeTranslationUITest.php @@ -501,4 +501,28 @@ public function testRevisionTranslationRendering() { $this->assertNoText('First rev en title'); } + /** + * Test that title is not escaped (but XSS-filtered) for details form element. + */ + public function testDetailsTitleIsNotEscaped() { + $this->drupalLogin($this->administrator); + // Make the image field a multi-value field in order to display a + // details form element. + $edit = ['cardinality_number' => 2]; + $this->drupalPostForm('admin/structure/types/manage/article/fields/node.article.field_image/storage', $edit, t('Save field settings')); + + // Make the image field non-translatable. + $edit = ['settings[node][article][fields][field_image]' => FALSE]; + $this->drupalPostForm('admin/config/regional/content-language', $edit, t('Save configuration')); + + // Create a node. + $nid = $this->createEntity(['title' => 'Node with multi-value image field en title'], 'en'); + + // Add a French translation and assert the title markup is not escaped. + $this->drupalGet("node/$nid/translations/add/en/fr"); + $markup = 'Image (all languages)'; + $this->assertSession()->assertNoEscaped($markup); + $this->assertSession()->responseContains($markup); + } + } diff --git a/core/modules/system/tests/modules/form_test/form_test.info.yml b/core/modules/system/tests/modules/form_test/form_test.info.yml index 39d45eaaf838..fdf1076ef604 100644 --- a/core/modules/system/tests/modules/form_test/form_test.info.yml +++ b/core/modules/system/tests/modules/form_test/form_test.info.yml @@ -4,3 +4,6 @@ description: 'Support module for Form API tests.' package: Testing version: VERSION core: 8.x +dependencies: + - file + - filter diff --git a/core/modules/system/tests/modules/form_test/src/Form/FormTestLabelForm.php b/core/modules/system/tests/modules/form_test/src/Form/FormTestLabelForm.php index 88de2300898a..a22bf7d09210 100644 --- a/core/modules/system/tests/modules/form_test/src/Form/FormTestLabelForm.php +++ b/core/modules/system/tests/modules/form_test/src/Form/FormTestLabelForm.php @@ -12,6 +12,40 @@ */ class FormTestLabelForm extends FormBase { + /** + * An array of elements that render a title. + * + * @var array + */ + public static $typesWithTitle = [ + 'checkbox', + 'checkboxes', + 'color', + 'date', + 'datelist', + 'datetime', + 'details', + 'email', + 'fieldset', + 'file', + 'item', + 'managed_file', + 'number', + 'password', + 'password_confirm', + 'radio', + 'radios', + 'range', + 'search', + 'select', + 'tel', + 'textarea', + 'textfield', + 'text_format', + 'url', + 'weight', + ]; + /** * {@inheritdoc} */ @@ -125,6 +159,21 @@ public function buildForm(array $form, FormStateInterface $form_state) { ], '#required' => TRUE, ]; + + foreach (static::$typesWithTitle as $type) { + $form['form_' . $type . '_title_no_xss'] = [ + '#type' => $type, + '#title' => "$type is XSS filtered!", + ]; + // Add keys that are required for some elements to be processed correctly. + if (in_array($type, ['checkboxes', 'radios'], TRUE)) { + $form['form_' . $type . '_title_no_xss']['#options'] = []; + } + if ($type === 'datetime') { + $form['form_' . $type . '_title_no_xss']['#default_value'] = NULL; + } + } + return $form; } diff --git a/core/modules/system/tests/src/Functional/Form/ElementsLabelsTest.php b/core/modules/system/tests/src/Functional/Form/ElementsLabelsTest.php index 6747ed63fb10..91f3ec4a59d4 100644 --- a/core/modules/system/tests/src/Functional/Form/ElementsLabelsTest.php +++ b/core/modules/system/tests/src/Functional/Form/ElementsLabelsTest.php @@ -2,6 +2,7 @@ namespace Drupal\Tests\system\Functional\Form; +use Drupal\form_test\Form\FormTestLabelForm; use Drupal\Tests\BrowserTestBase; /** @@ -96,6 +97,17 @@ public function testFormLabels() { $this->assertTrue(!empty($elements), "Title/Label not displayed when 'visually-hidden' attribute is set in radios."); } + /** + * Tests XSS-protection of element labels. + */ + public function testTitleEscaping() { + $this->drupalGet('form_test/form-labels'); + foreach (FormTestLabelForm::$typesWithTitle as $type) { + $this->assertSession()->responseContains("$type alert('XSS') is XSS filtered!"); + $this->assertSession()->responseNotContains("$type is XSS filtered!"); + } + } + /** * Tests different display options for form element descriptions. */ From 4dc2f4aea8bea761b6b51b366dea0d288e001b34 Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Fri, 22 Jun 2018 14:22:28 +0100 Subject: [PATCH 14/39] Issue #2658956 by Daniel_Rempe, Lendude, mikeker, catch, Jeff Cardwell, tstoeckler, hctom, dawehner, alexpott: Taxonomy vocabulary data not available as views fields --- core/modules/taxonomy/src/TermViewsData.php | 2 +- .../views.view.test_taxonomy_vid_field.yml | 163 ++++++++++++++++++ .../src/Kernel/Views/TaxonomyFieldVidTest.php | 92 ++++++++++ 3 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 core/modules/taxonomy/tests/modules/taxonomy_test_views/test_views/views.view.test_taxonomy_vid_field.yml create mode 100644 core/modules/taxonomy/tests/src/Kernel/Views/TaxonomyFieldVidTest.php diff --git a/core/modules/taxonomy/src/TermViewsData.php b/core/modules/taxonomy/src/TermViewsData.php index 469c3e83583f..6cade9a1baeb 100644 --- a/core/modules/taxonomy/src/TermViewsData.php +++ b/core/modules/taxonomy/src/TermViewsData.php @@ -66,7 +66,7 @@ public function getViewsData() { ]; $data['taxonomy_term_field_data']['vid']['help'] = $this->t('Filter the results of "Taxonomy: Term" to a particular vocabulary.'); - unset($data['taxonomy_term_field_data']['vid']['field']); + $data['taxonomy_term_field_data']['vid']['field']['help'] = t('The vocabulary name.'); $data['taxonomy_term_field_data']['vid']['argument']['id'] = 'vocabulary_vid'; unset($data['taxonomy_term_field_data']['vid']['sort']); diff --git a/core/modules/taxonomy/tests/modules/taxonomy_test_views/test_views/views.view.test_taxonomy_vid_field.yml b/core/modules/taxonomy/tests/modules/taxonomy_test_views/test_views/views.view.test_taxonomy_vid_field.yml new file mode 100644 index 000000000000..ec5ae2dacb84 --- /dev/null +++ b/core/modules/taxonomy/tests/modules/taxonomy_test_views/test_views/views.view.test_taxonomy_vid_field.yml @@ -0,0 +1,163 @@ +langcode: en +status: true +dependencies: + module: + - taxonomy + - user +id: test_taxonomy_vid_field +label: test_taxonomy_vid_field +module: views +description: '' +tag: '' +base_table: taxonomy_term_field_data +base_field: tid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access content' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: full + options: + items_per_page: 10 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: '‹ Previous' + next: 'Next ›' + first: '« First' + last: 'Last »' + quantity: 9 + style: + type: default + options: + grouping: { } + row_class: '' + default_row_class: true + uses_fields: false + row: + type: fields + options: + inline: { } + separator: '' + hide_empty: false + default_field_elements: true + fields: + vid: + id: vid + table: taxonomy_term_field_data + field: vid + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_label + settings: + link: false + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: taxonomy_term + entity_field: vid + plugin_id: field + filters: { } + sorts: { } + header: { } + footer: { } + empty: { } + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url.query_args + - user.permissions + tags: { } diff --git a/core/modules/taxonomy/tests/src/Kernel/Views/TaxonomyFieldVidTest.php b/core/modules/taxonomy/tests/src/Kernel/Views/TaxonomyFieldVidTest.php new file mode 100644 index 000000000000..2fde283e3251 --- /dev/null +++ b/core/modules/taxonomy/tests/src/Kernel/Views/TaxonomyFieldVidTest.php @@ -0,0 +1,92 @@ +installEntitySchema('taxonomy_term'); + $this->installEntitySchema('user'); + $this->installConfig(['filter']); + + /** @var \Drupal\taxonomy\Entity\Vocabulary $vocabulary */ + $vocabulary = $this->createVocabulary(); + $this->term1 = $this->createTerm($vocabulary); + + // Create user 1 and set is as the logged in user, so that the logged in + // user has the correct permissions to view the vocabulary name. + $this->adminUser = User::create(['name' => $this->randomString()]); + $this->adminUser->save(); + $this->container->get('current_user')->setAccount($this->adminUser); + + ViewTestData::createTestViews(get_class($this), ['taxonomy_test_views']); + } + + /** + * Tests the field handling for the Vocabulary ID. + */ + public function testViewsHandlerVidField() { + /** @var \Drupal\Core\Render\RendererInterface $renderer */ + $renderer = \Drupal::service('renderer'); + + $view = Views::getView('test_taxonomy_vid_field'); + $this->executeView($view); + + $actual = $renderer->executeInRenderContext(new RenderContext(), function () use ($view) { + return $view->field['vid']->advancedRender($view->result[0]); + }); + $vocabulary = Vocabulary::load($this->term1->bundle()); + $expected = $vocabulary->get('name'); + + $this->assertEquals($expected, $actual); + } + +} From d4350e2b1ef7d92b9028dd302383955314b15a6c Mon Sep 17 00:00:00 2001 From: Nathaniel Catchpole Date: Fri, 22 Jun 2018 15:05:00 +0100 Subject: [PATCH 15/39] Issue #2962110 by samuel.mortenson, drpal, andrewmacpherson, ckrina, phenaproxima, yoroy, webchick, amateescu, chr.fritsch, starshaped, lauriii, webflo, Dennis Cohn, dawehner, seanB, rfmarcelino, DyanneNova, benjifisher, jan.stoeckler, danbohea, John Pitcairn: Add the Media Library module to Drupal core --- core/composer.json | 1 + ...e.entity_view_mode.media.media_library.yml | 12 + .../install/views.view.media_library.yml | 447 ++++++++++++++++++ ...view_display.media.audio.media_library.yml | 29 ++ ..._view_display.media.file.media_library.yml | 29 ++ ...view_display.media.image.media_library.yml | 29 ++ ...view_display.media.video.media_library.yml | 29 ++ .../css/media_library.module.css | 60 +++ .../media_library/css/media_library.theme.css | 151 ++++++ .../js/media_library.click_to_select.es6.js | 31 ++ .../js/media_library.click_to_select.js | 24 + .../js/media_library.view.es6.js | 58 +++ .../media_library/js/media_library.view.js | 50 ++ .../media_library/media_library.info.yml | 10 + .../media_library/media_library.install | 49 ++ .../media_library/media_library.libraries.yml | 25 + .../media_library.links.action.yml | 5 + .../media_library.links.task.yml | 10 + .../media_library/media_library.module | 109 +++++ .../templates/media--media-library.html.twig | 50 ++ ...ty_form_display.media.type_one.default.yml | 43 ++ ...ty_form_display.media.type_two.default.yml | 43 ++ ...ty_view_display.media.type_one.default.yml | 50 ++ ...ty_view_display.media.type_two.default.yml | 50 ++ ....field.media.type_one.field_media_test.yml | 18 + ...ield.media.type_two.field_media_test_1.yml | 18 + .../field.storage.media.field_media_test.yml | 20 + ...field.storage.media.field_media_test_1.yml | 20 + .../config/install/media.type.type_one.yml | 16 + .../config/install/media.type.type_two.yml | 16 + .../media_library_test.info.yml | 10 + .../FunctionalJavascript/MediaLibraryTest.php | 102 ++++ 32 files changed, 1614 insertions(+) create mode 100644 core/modules/media_library/config/install/core.entity_view_mode.media.media_library.yml create mode 100644 core/modules/media_library/config/install/views.view.media_library.yml create mode 100644 core/modules/media_library/config/optional/core.entity_view_display.media.audio.media_library.yml create mode 100644 core/modules/media_library/config/optional/core.entity_view_display.media.file.media_library.yml create mode 100644 core/modules/media_library/config/optional/core.entity_view_display.media.image.media_library.yml create mode 100644 core/modules/media_library/config/optional/core.entity_view_display.media.video.media_library.yml create mode 100644 core/modules/media_library/css/media_library.module.css create mode 100644 core/modules/media_library/css/media_library.theme.css create mode 100644 core/modules/media_library/js/media_library.click_to_select.es6.js create mode 100644 core/modules/media_library/js/media_library.click_to_select.js create mode 100644 core/modules/media_library/js/media_library.view.es6.js create mode 100644 core/modules/media_library/js/media_library.view.js create mode 100644 core/modules/media_library/media_library.info.yml create mode 100644 core/modules/media_library/media_library.install create mode 100644 core/modules/media_library/media_library.libraries.yml create mode 100644 core/modules/media_library/media_library.links.action.yml create mode 100644 core/modules/media_library/media_library.links.task.yml create mode 100644 core/modules/media_library/media_library.module create mode 100644 core/modules/media_library/templates/media--media-library.html.twig create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_one.default.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_two.default.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_one.default.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_two.default.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_one.field_media_test.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_two.field_media_test_1.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test_1.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_one.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_two.yml create mode 100644 core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml create mode 100644 core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php diff --git a/core/composer.json b/core/composer.json index 7452a077766a..770631ddfd27 100644 --- a/core/composer.json +++ b/core/composer.json @@ -133,6 +133,7 @@ "drupal/locale": "self.version", "drupal/minimal": "self.version", "drupal/media": "self.version", + "drupal/media_library": "self.version", "drupal/menu_link_content": "self.version", "drupal/menu_ui": "self.version", "drupal/migrate": "self.version", diff --git a/core/modules/media_library/config/install/core.entity_view_mode.media.media_library.yml b/core/modules/media_library/config/install/core.entity_view_mode.media.media_library.yml new file mode 100644 index 000000000000..3406f026b491 --- /dev/null +++ b/core/modules/media_library/config/install/core.entity_view_mode.media.media_library.yml @@ -0,0 +1,12 @@ +langcode: en +status: true +dependencies: + enforced: + module: + - media_library + module: + - media +id: media.media_library +label: 'Media library' +targetEntityType: media +cache: true diff --git a/core/modules/media_library/config/install/views.view.media_library.yml b/core/modules/media_library/config/install/views.view.media_library.yml new file mode 100644 index 000000000000..8c9e784e6008 --- /dev/null +++ b/core/modules/media_library/config/install/views.view.media_library.yml @@ -0,0 +1,447 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + enforced: + module: + - media_library + module: + - media + - user +id: media_library +label: 'Media library' +module: views +description: '' +tag: '' +base_table: media_field_data +base_field: mid +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'access media overview' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: 'Apply Filters' + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: false + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 25 + offset: 0 + id: 0 + total_pages: null + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + tags: + previous: ‹‹ + next: ›› + style: + type: default + options: + grouping: { } + row_class: 'media-library-item js-click-to-select' + default_row_class: true + row: + type: fields + options: + default_field_elements: true + inline: { } + separator: '' + hide_empty: false + fields: + media_bulk_form: + id: media_bulk_form + table: media + field: media_bulk_form + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: js-click-to-select__checkbox + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + action_title: Action + include_exclude: exclude + selected_actions: { } + entity_type: media + plugin_id: bulk_form + rendered_entity: + id: rendered_entity + table: media + field: rendered_entity + relationship: none + group_type: group + admin_label: '' + label: '' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: media-library-item__content + element_label_type: '' + element_label_class: '' + element_label_colon: false + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + view_mode: media_library + entity_type: media + plugin_id: rendered_entity + filters: + status: + id: status + table: media_field_data + field: status + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '1' + group: 1 + exposed: true + expose: + operator_id: '' + label: 'Publishing status' + description: null + use_operator: false + operator: status_op + identifier: status + required: true + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: true + group_info: + label: Published + description: '' + identifier: status + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: + 1: + title: Published + operator: '=' + value: '1' + 2: + title: Unpublished + operator: '=' + value: '0' + plugin_id: boolean + entity_type: media + entity_field: status + name: + id: name + table: media_field_data + field: name + relationship: none + group_type: group + admin_label: '' + operator: contains + value: '' + group: 1 + exposed: true + expose: + operator_id: name_op + label: Name + description: '' + use_operator: false + operator: name_op + identifier: name + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: media + entity_field: name + plugin_id: string + bundle: + id: bundle + table: media_field_data + field: bundle + relationship: none + group_type: group + admin_label: '' + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: bundle_op + label: 'Media type' + description: '' + use_operator: false + operator: bundle_op + identifier: type + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + reduce: false + is_grouped: false + group_info: + label: 'Media type' + description: null + identifier: bundle + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: + 1: { } + 2: { } + 3: { } + entity_type: media + entity_field: bundle + plugin_id: bundle + sorts: + created: + id: created + table: media_field_data + field: created + relationship: none + group_type: group + admin_label: '' + order: DESC + exposed: true + expose: + label: 'Newest first' + granularity: second + entity_type: media + entity_field: created + plugin_id: date + name: + id: name + table: media_field_data + field: name + relationship: none + group_type: group + admin_label: '' + order: ASC + exposed: true + expose: + label: 'Name (A-Z)' + entity_type: media + entity_field: name + plugin_id: standard + name_1: + id: name_1 + table: media_field_data + field: name + relationship: none + group_type: group + admin_label: '' + order: DESC + exposed: true + expose: + label: 'Name (Z-A)' + entity_type: media + entity_field: name + plugin_id: standard + title: Media + header: { } + footer: { } + empty: { } + relationships: { } + arguments: + bundle: + id: bundle + table: media_field_data + field: bundle + relationship: none + group_type: group + admin_label: '' + default_action: ignore + exception: + value: all + title_enable: false + title: All + title_enable: false + title: '' + default_argument_type: fixed + default_argument_options: + argument: '' + default_argument_skip_url: false + summary_options: + base_path: '' + count: true + items_per_page: 25 + override: false + summary: + sort_order: asc + number_of_records: 0 + format: default_summary + specify_validation: false + validate: + type: none + fail: 'not found' + validate_options: { } + glossary: false + limit: 0 + case: none + path_case: none + transform_dash: false + break_phrase: false + entity_type: media + entity_field: bundle + plugin_id: string + display_extenders: { } + use_ajax: true + css_class: media-library-view + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_interface' + - url + - url.query_args + - 'url.query_args:sort_by' + - user.permissions + tags: { } + page: + display_plugin: page + id: page + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: admin/content/media + menu: + type: tab + title: Media + description: 'Allows users to browse and administer media items' + expanded: false + parent: system.admin_content + weight: 5 + context: '0' + menu_name: admin + cache_metadata: + max-age: 0 + contexts: + - 'languages:language_interface' + - url + - url.query_args + - 'url.query_args:sort_by' + - user.permissions + tags: { } diff --git a/core/modules/media_library/config/optional/core.entity_view_display.media.audio.media_library.yml b/core/modules/media_library/config/optional/core.entity_view_display.media.audio.media_library.yml new file mode 100644 index 000000000000..e74e3ebde6b2 --- /dev/null +++ b/core/modules/media_library/config/optional/core.entity_view_display.media.audio.media_library.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.audio.field_media_audio_file + - image.style.thumbnail + - media.type.audio + module: + - image +id: media.audio.media_library +targetEntityType: media +bundle: audio +mode: media_library +content: + thumbnail: + type: image + weight: 0 + region: content + label: hidden + settings: + image_style: thumbnail + image_link: '' + third_party_settings: { } +hidden: + created: true + field_media_audio_file: true + name: true + uid: true diff --git a/core/modules/media_library/config/optional/core.entity_view_display.media.file.media_library.yml b/core/modules/media_library/config/optional/core.entity_view_display.media.file.media_library.yml new file mode 100644 index 000000000000..e09e611b9550 --- /dev/null +++ b/core/modules/media_library/config/optional/core.entity_view_display.media.file.media_library.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.file.field_media_file + - image.style.thumbnail + - media.type.file + module: + - image +id: media.file.media_library +targetEntityType: media +bundle: file +mode: media_library +content: + thumbnail: + type: image + weight: 0 + region: content + label: hidden + settings: + image_style: thumbnail + image_link: '' + third_party_settings: { } +hidden: + created: true + field_media_file: true + name: true + uid: true diff --git a/core/modules/media_library/config/optional/core.entity_view_display.media.image.media_library.yml b/core/modules/media_library/config/optional/core.entity_view_display.media.image.media_library.yml new file mode 100644 index 000000000000..a916760ad99b --- /dev/null +++ b/core/modules/media_library/config/optional/core.entity_view_display.media.image.media_library.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.image.field_media_image + - image.style.medium + - media.type.image + module: + - image +id: media.image.media_library +targetEntityType: media +bundle: image +mode: media_library +content: + thumbnail: + type: image + weight: 0 + region: content + label: hidden + settings: + image_style: medium + image_link: '' + third_party_settings: { } +hidden: + created: true + field_media_image: true + name: true + uid: true diff --git a/core/modules/media_library/config/optional/core.entity_view_display.media.video.media_library.yml b/core/modules/media_library/config/optional/core.entity_view_display.media.video.media_library.yml new file mode 100644 index 000000000000..33d4e8855553 --- /dev/null +++ b/core/modules/media_library/config/optional/core.entity_view_display.media.video.media_library.yml @@ -0,0 +1,29 @@ +langcode: en +status: true +dependencies: + config: + - core.entity_view_mode.media.media_library + - field.field.media.video.field_media_video_file + - image.style.thumbnail + - media.type.video + module: + - image +id: media.video.media_library +targetEntityType: media +bundle: video +mode: media_library +content: + thumbnail: + type: image + weight: 0 + region: content + label: hidden + settings: + image_style: thumbnail + image_link: '' + third_party_settings: { } +hidden: + created: true + field_media_video_file: true + name: true + uid: true diff --git a/core/modules/media_library/css/media_library.module.css b/core/modules/media_library/css/media_library.module.css new file mode 100644 index 000000000000..11ad56dd922e --- /dev/null +++ b/core/modules/media_library/css/media_library.module.css @@ -0,0 +1,60 @@ +/** +* @file media_library.module.css +*/ + +.media-library-page-form { + display: flex; + flex-wrap: wrap; +} + +.media-library-page-form > .form-actions { + flex-basis: 100%; +} + +.media-library-page-form__header > div, +.media-library-view .form--inline { + display: flex; + flex-wrap: wrap; +} + +.media-library-page-form__header { + flex-basis: 100%; +} + +.media-library-item { + position: relative; +} + +.media-library-item .js-click-to-select__trigger { + overflow: hidden; + cursor: pointer; +} + +.media-library-view .form-actions { + align-self: flex-end; +} + +.media-library-item .js-click-to-select__checkbox { + position: absolute; + display: block; + z-index: 1; + top: 5px; + right: 0; +} + +.media-library-item__status { + position: absolute; + top: 10px; + left: 2px; + pointer-events: none; +} + +.media-library-select-all { + flex-basis: 100%; +} + +@media screen and (max-width: 600px) { + .media-library-view .form-actions { + flex-basis: 100%; + } +} diff --git a/core/modules/media_library/css/media_library.theme.css b/core/modules/media_library/css/media_library.theme.css new file mode 100644 index 000000000000..0427143538e3 --- /dev/null +++ b/core/modules/media_library/css/media_library.theme.css @@ -0,0 +1,151 @@ +/** + * @file media_library.theme.css + * + * @todo Move into the Seven theme when this module is marked as stable. + * @see https://www.drupal.org/project/drupal/issues/2980769 + */ + +.media-library-page-form__header .form-item { + margin-right: 8px; +} + +#drupal-modal .view-header { + margin: 16px 0; +} + +.media-library-item { + justify-content: center; + vertical-align: top; + padding: 2px; + border: 1px solid #ebebeb; + margin: 16px 16px 2px 2px; + width: 180px; + background: #fff; + transition: border-color 0.2s, color 0.2s, background 0.2s; +} + +.media-library-view .form-actions { + margin: 0.75em 0; +} + +.media-library-item .field--name-thumbnail { + background-color: #ebebeb; + margin: 2px; + overflow: hidden; + text-align: center; +} + +.media-library-item .field--name-thumbnail img { + height: 180px; + object-fit: contain; + object-position: center center; +} + +.media-library-item.is-hover, +.media-library-item.checked, +.media-library-item.is-focus { + border-color: #40b6ff; + border-width: 3px; + border-radius: 3px; + margin: 14px 14px 0 0; +} + +.media-library-item.checked { + border-color: #0076c0; +} + +.media-library-item .js-click-to-select__checkbox input { + width: 30px; + height: 30px; +} + +.media-library-item .js-click-to-select__checkbox .form-item { + margin: 0; +} + +.media-library-item__preview { + padding-bottom: 44px; +} + +.media-library-item__status { + color: #e4e4e4; + font-style: italic; + background: #666; + padding: 5px 10px; + font-size: 12px; +} + +.media-library-item .views-field-operations { + height: 30px; +} + +.media-library-item .views-field-operations .dropbutton-wrapper { + display: inline-block; + position: absolute; + right: 5px; + bottom: 5px; +} + +.media-library-item__attributes { + position: absolute; + bottom: 0; + display: block; + padding: 10px; + max-width: calc(100% - 20px); + max-height: calc(100% - 60px); + overflow: hidden; + background: white; +} + +.media-library-item__name { + font-size: 14px; +} + +.media-library-item__name a { + display: block; + text-decoration: underline; + margin: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.media-library-item__attributes:hover .media-library-item__name a, +.media-library-item__name a:focus, +.media-library-item.is-focus .media-library-item__name a, +.media-library-item.checked .media-library-item__name a { + white-space: normal; +} + +.media-library-item__name a:focus { + border: 2px solid; + margin: 0; +} + +.media-library-item__type { + font-size: 12px; + color: #696969; +} + +.media-library-select-all { + margin: 10px 0 10px 0; +} + +.media-library-select-all input { + margin-right: 10px; +} + +@media screen and (max-width: 600px) { + .media-library-item { + width: 150px; + } + .media-library-item .field--name-thumbnail img { + height: 150px; + width: 150px; + } + .media-library-item .views-field-operations .dropbutton-wrapper { + position: relative; + right: 0; + border: 0; + } +} diff --git a/core/modules/media_library/js/media_library.click_to_select.es6.js b/core/modules/media_library/js/media_library.click_to_select.es6.js new file mode 100644 index 000000000000..321ffe0c73a9 --- /dev/null +++ b/core/modules/media_library/js/media_library.click_to_select.es6.js @@ -0,0 +1,31 @@ +/** + * @file media_library.click_to_select.es6.js + */ + +(($, Drupal) => { + /** + * Allows users to select an element which checks a hidden checkbox. + */ + Drupal.behaviors.ClickToSelect = { + attach(context) { + $('.js-click-to-select__trigger', context) + .once('media-library-click-to-select') + .on('click', (event) => { + // Links inside the trigger should not be click-able. + event.preventDefault(); + // Click the hidden checkbox when the trigger is clicked. + const $input = $(event.currentTarget) + .closest('.js-click-to-select') + .find('.js-click-to-select__checkbox input'); + $input.prop('checked', !$input.prop('checked')).trigger('change'); + }); + $('.js-click-to-select__checkbox input', context) + .once('media-library-click-to-select') + .on('change', ({ currentTarget }) => { + $(currentTarget) + .closest('.js-click-to-select') + .toggleClass('checked', $(currentTarget).prop('checked')); + }); + }, + }; +})(jQuery, Drupal); diff --git a/core/modules/media_library/js/media_library.click_to_select.js b/core/modules/media_library/js/media_library.click_to_select.js new file mode 100644 index 000000000000..4bc041c43e56 --- /dev/null +++ b/core/modules/media_library/js/media_library.click_to_select.js @@ -0,0 +1,24 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, Drupal) { + Drupal.behaviors.ClickToSelect = { + attach: function attach(context) { + $('.js-click-to-select__trigger', context).once('media-library-click-to-select').on('click', function (event) { + event.preventDefault(); + + var $input = $(event.currentTarget).closest('.js-click-to-select').find('.js-click-to-select__checkbox input'); + $input.prop('checked', !$input.prop('checked')).trigger('change'); + }); + $('.js-click-to-select__checkbox input', context).once('media-library-click-to-select').on('change', function (_ref) { + var currentTarget = _ref.currentTarget; + + $(currentTarget).closest('.js-click-to-select').toggleClass('checked', $(currentTarget).prop('checked')); + }); + } + }; +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/media_library/js/media_library.view.es6.js b/core/modules/media_library/js/media_library.view.es6.js new file mode 100644 index 000000000000..26e9a5b2635e --- /dev/null +++ b/core/modules/media_library/js/media_library.view.es6.js @@ -0,0 +1,58 @@ +/** + * @file media_library.view.es6.js + */ +(($, Drupal) => { + /** + * Adds hover effect to media items. + */ + Drupal.behaviors.MediaLibraryHover = { + attach(context) { + $('.media-library-item .js-click-to-select__trigger,.media-library-item .js-click-to-select__checkbox', context).once('media-library-item-hover') + .on('mouseover mouseout', ({ currentTarget, type }) => { + $(currentTarget).closest('.media-library-item').toggleClass('is-hover', type === 'mouseover'); + }); + }, + }; + + /** + * Adds focus effect to media items. + */ + Drupal.behaviors.MediaLibraryFocus = { + attach(context) { + $('.media-library-item .js-click-to-select__checkbox input', context).once('media-library-item-focus') + .on('focus blur', ({ currentTarget, type }) => { + $(currentTarget).closest('.media-library-item').toggleClass('is-focus', type === 'focus'); + }); + }, + }; + + /** + * Adds checkbox to select all items in the library. + */ + Drupal.behaviors.MediaLibrarySelectAll = { + attach(context) { + const $view = $('.media-library-view', context).once('media-library-select-all'); + if ($view.length && $view.find('.media-library-item').length) { + const $checkbox = $('') + .on('click', ({ currentTarget }) => { + // Toggle all checkboxes. + const $checkboxes = $(currentTarget) + .closest('.media-library-view') + .find('.media-library-item input[type="checkbox"]'); + $checkboxes + .prop('checked', $(currentTarget).prop('checked')) + .trigger('change'); + // Announce the selection. + const announcement = $(currentTarget).prop('checked') ? + Drupal.t('Zero items selected') : + Drupal.t('All @count items selected', { '@count': $checkboxes.length }); + Drupal.announce(announcement); + }); + const $label = $('') + .text(Drupal.t('Select all media')); + $label.prepend($checkbox); + $view.find('.media-library-item').first().before($label); + } + }, + }; +})(jQuery, Drupal); diff --git a/core/modules/media_library/js/media_library.view.js b/core/modules/media_library/js/media_library.view.js new file mode 100644 index 000000000000..1cde60c3acfb --- /dev/null +++ b/core/modules/media_library/js/media_library.view.js @@ -0,0 +1,50 @@ +/** +* DO NOT EDIT THIS FILE. +* See the following change record for more information, +* https://www.drupal.org/node/2815083 +* @preserve +**/ + +(function ($, Drupal) { + Drupal.behaviors.MediaLibraryHover = { + attach: function attach(context) { + $('.media-library-item .js-click-to-select__trigger,.media-library-item .js-click-to-select__checkbox', context).once('media-library-item-hover').on('mouseover mouseout', function (_ref) { + var currentTarget = _ref.currentTarget, + type = _ref.type; + + $(currentTarget).closest('.media-library-item').toggleClass('is-hover', type === 'mouseover'); + }); + } + }; + + Drupal.behaviors.MediaLibraryFocus = { + attach: function attach(context) { + $('.media-library-item .js-click-to-select__checkbox input', context).once('media-library-item-focus').on('focus blur', function (_ref2) { + var currentTarget = _ref2.currentTarget, + type = _ref2.type; + + $(currentTarget).closest('.media-library-item').toggleClass('is-focus', type === 'focus'); + }); + } + }; + + Drupal.behaviors.MediaLibrarySelectAll = { + attach: function attach(context) { + var $view = $('.media-library-view', context).once('media-library-select-all'); + if ($view.length && $view.find('.media-library-item').length) { + var $checkbox = $('').on('click', function (_ref3) { + var currentTarget = _ref3.currentTarget; + + var $checkboxes = $(currentTarget).closest('.media-library-view').find('.media-library-item input[type="checkbox"]'); + $checkboxes.prop('checked', $(currentTarget).prop('checked')).trigger('change'); + + var announcement = $(currentTarget).prop('checked') ? Drupal.t('Zero items selected') : Drupal.t('All @count items selected', { '@count': $checkboxes.length }); + Drupal.announce(announcement); + }); + var $label = $('').text(Drupal.t('Select all media')); + $label.prepend($checkbox); + $view.find('.media-library-item').first().before($label); + } + } + }; +})(jQuery, Drupal); \ No newline at end of file diff --git a/core/modules/media_library/media_library.info.yml b/core/modules/media_library/media_library.info.yml new file mode 100644 index 000000000000..9f7aa912e405 --- /dev/null +++ b/core/modules/media_library/media_library.info.yml @@ -0,0 +1,10 @@ +name: 'Media library' +type: module +description: 'Provides a library for re-using Media Items.' +package: Core (Experimental) +version: VERSION +core: 8.x +dependencies: + - drupal:media + - drupal:views + - drupal:user diff --git a/core/modules/media_library/media_library.install b/core/modules/media_library/media_library.install new file mode 100644 index 000000000000..79a97868b7b6 --- /dev/null +++ b/core/modules/media_library/media_library.install @@ -0,0 +1,49 @@ +getDisplay('media_page_list'); + if (!empty($display)) { + $display['display_options']['path'] = 'admin/content/media-table'; + unset($display['display_options']['menu']); + $view->trustData()->save(); + } + } +} + +/** + * Implements hook_uninstall(). + */ +function media_library_uninstall() { + // Restore the path to the original media view. + /** @var \Drupal\views\Entity\View $view */ + if ($view = View::load('media')) { + $display = &$view->getDisplay('media_page_list'); + if (!empty($display)) { + $display['display_options']['path'] = 'admin/content/media'; + $display['display_options']['menu'] = [ + 'type' => 'tab', + 'title' => 'Media', + 'description' => '', + 'expanded' => FALSE, + 'parent' => '', + 'weight' => 0, + 'context' => '0', + 'menu_name' => 'main', + ]; + $view->trustData()->save(); + } + } +} diff --git a/core/modules/media_library/media_library.libraries.yml b/core/modules/media_library/media_library.libraries.yml new file mode 100644 index 000000000000..e32dbe074b49 --- /dev/null +++ b/core/modules/media_library/media_library.libraries.yml @@ -0,0 +1,25 @@ +style: + version: VERSION + css: + component: + css/media_library.module.css: {} + theme: + css/media_library.theme.css: {} + +click_to_select: + version: VERSION + js: + js/media_library.click_to_select.js: {} + dependencies: + - core/drupal + - core/jquery.once + +view: + version: VERSION + js: + js/media_library.view.js: {} + dependencies: + - media_library/style + - media_library/click_to_select + - core/drupal.announce + - core/jquery.once diff --git a/core/modules/media_library/media_library.links.action.yml b/core/modules/media_library/media_library.links.action.yml new file mode 100644 index 000000000000..6a37769cf17f --- /dev/null +++ b/core/modules/media_library/media_library.links.action.yml @@ -0,0 +1,5 @@ +media_library.add: + route_name: entity.media.add_page + title: 'Add media' + appears_on: + - view.media_library.page diff --git a/core/modules/media_library/media_library.links.task.yml b/core/modules/media_library/media_library.links.task.yml new file mode 100644 index 000000000000..757ecd853249 --- /dev/null +++ b/core/modules/media_library/media_library.links.task.yml @@ -0,0 +1,10 @@ +media_library.grid: + title: 'Grid' + parent_id: entity.media.collection + route_name: entity.media.collection + weight: 10 +media_library.table: + title: 'Table' + parent_id: entity.media.collection + route_name: view.media.media_page_list + weight: 20 diff --git a/core/modules/media_library/media_library.module b/core/modules/media_library/media_library.module new file mode 100644 index 000000000000..63b3cadb96a8 --- /dev/null +++ b/core/modules/media_library/media_library.module @@ -0,0 +1,109 @@ +' . t('About') . '
'; + $output .= '

' . t('The Media library module overrides the /admin/content/media view to provide a rich visual interface for performing administrative operations on media. For more information, see the online documentation for the Media library module.', [':media' => 'https://www.drupal.org/docs/8/core/modules/media']) . '

'; + return $output; + } +} + +/** + * Implements hook_theme(). + */ +function media_library_theme() { + return [ + 'media__media_library' => [ + 'base hook' => 'media', + ], + ]; +} + +/** + * Implements hook_views_post_render(). + */ +function media_library_views_post_render(ViewExecutable $view, &$output, CachePluginBase $cache) { + if ($view->id() === 'media_library') { + $output['#attached']['library'][] = 'media_library/view'; + } +} + +/** + * Implements hook_preprocess_media(). + */ +function media_library_preprocess_media(&$variables) { + if ($variables['view_mode'] === 'media_library') { + /** @var \Drupal\media\MediaInterface $media */ + $media = $variables['media']; + $variables['#cache']['contexts'][] = 'user.permissions'; + $rel = $media->access('edit') ? 'edit-form' : 'canonical'; + $variables['url'] = $media->toUrl($rel, [ + 'language' => $media->language(), + ]); + $variables['preview_attributes'] = new Attribute(); + $variables['preview_attributes']->addClass('media-library-item__preview', 'js-click-to-select__trigger'); + $variables['metadata_attributes'] = new Attribute(); + $variables['metadata_attributes']->addClass('media-library-item__attributes'); + $variables['status'] = $media->isPublished(); + } +} + +/** + * Alter the bulk form to add a more accessible label. + * + * @param array $form + * An associative array containing the structure of the form. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The current state of the form. + * + * @todo Remove in https://www.drupal.org/project/drupal/issues/2969660 + */ +function media_library_form_views_form_media_library_page_alter(array &$form, FormStateInterface $form_state) { + if (isset($form['media_bulk_form']) && isset($form['output'])) { + $form['#attributes']['class'][] = 'media-library-page-form'; + $form['header']['#attributes']['class'][] = 'media-library-page-form__header'; + /** @var \Drupal\views\ViewExecutable $view */ + $view = $form['output'][0]['#view']; + foreach (Element::getVisibleChildren($form['media_bulk_form']) as $key) { + if (isset($view->result[$key])) { + $media = $view->field['media_bulk_form']->getEntity($view->result[$key]); + $form['media_bulk_form'][$key]['#title'] = t('Select @label', [ + '@label' => $media->label(), + ]); + } + } + } +} + +/** + * Implements hook_local_tasks_alter(). + * + * Removes tasks for the Media library if the view display no longer exists. + */ +function media_library_local_tasks_alter(&$local_tasks) { + /** @var \Symfony\Component\Routing\RouteCollection $route_collection */ + $route_collection = \Drupal::service('router')->getRouteCollection(); + foreach (['media_library.grid', 'media_library.table'] as $key) { + if (isset($local_tasks[$key]) && !$route_collection->get($local_tasks[$key]['route_name'])) { + unset($local_tasks[$key]); + } + } +} diff --git a/core/modules/media_library/templates/media--media-library.html.twig b/core/modules/media_library/templates/media--media-library.html.twig new file mode 100644 index 000000000000..a55024a22d2d --- /dev/null +++ b/core/modules/media_library/templates/media--media-library.html.twig @@ -0,0 +1,50 @@ +{# +/** + * @file + * Default theme implementation to present a media entity in the media library. + * + * Available variables: + * - media: The entity with limited access to object properties and methods. + * Only method names starting with "get", "has", or "is" and a few common + * methods such as "id", "label", and "bundle" are available. For example: + * - entity.getEntityTypeId() will return the entity type ID. + * - entity.hasField('field_example') returns TRUE if the entity includes + * field_example. (This does not indicate the presence of a value in this + * field.) + * Calling other methods, such as entity.delete(), will result in an exception. + * See \Drupal\Core\Entity\EntityInterface for a full list of methods. + * - name: Name of the media. + * - content: Media content. + * - title_prefix: Additional output populated by modules, intended to be + * displayed in front of the main title tag that appears in the template. + * - title_suffix: Additional output populated by modules, intended to be + * displayed after the main title tag that appears in the template. + * - view_mode: View mode; for example, "teaser" or "full". + * - attributes: HTML attributes for the containing element. + * - title_attributes: Same as attributes, except applied to the main title + * tag that appears in the template. + * - url: Direct URL of the media. + * - preview_attributes: HTML attributes for the preview wrapper. + * - metadata_attributes: HTML attributes for the expandable metadata area. + * - status: Whether or not the Media is published. + * + * @see template_preprocess_media() + * + * @ingroup themeable + */ +#} + + {% if content %} + + {{ content|without('name') }} + + {% if not status %} +
{{ "unpublished" | t }}
+ {% endif %} + + + + {% endif %} + diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_one.default.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_one.default.yml new file mode 100644 index 000000000000..212daf818796 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_one.default.yml @@ -0,0 +1,43 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.type_one.field_media_test + - media.type.type_one +id: media.type_one.default +targetEntityType: media +bundle: type_one +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_test: + settings: + size: 60 + placeholder: '' + third_party_settings: { } + type: string_textfield + weight: 11 + region: content + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + settings: + match_operator: CONTAINS + size: 60 + placeholder: '' + region: content + third_party_settings: { } +hidden: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_two.default.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_two.default.yml new file mode 100644 index 000000000000..fabd13b0297b --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_form_display.media.type_two.default.yml @@ -0,0 +1,43 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.type_two.field_media_test_1 + - media.type.type_two +id: media.type_two.default +targetEntityType: media +bundle: type_two +mode: default +content: + created: + type: datetime_timestamp + weight: 10 + region: content + settings: { } + third_party_settings: { } + field_media_test_1: + settings: + size: 60 + placeholder: '' + third_party_settings: { } + type: string_textfield + weight: 11 + region: content + name: + type: string_textfield + weight: -5 + region: content + settings: + size: 60 + placeholder: '' + third_party_settings: { } + uid: + type: entity_reference_autocomplete + weight: 5 + settings: + match_operator: CONTAINS + size: 60 + placeholder: '' + region: content + third_party_settings: { } +hidden: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_one.default.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_one.default.yml new file mode 100644 index 000000000000..95670b3557bb --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_one.default.yml @@ -0,0 +1,50 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.type_one.field_media_test + - image.style.thumbnail + - media.type.type_one + module: + - image + - user +id: media.type_one.default +targetEntityType: media +bundle: type_one +mode: default +content: + created: + label: hidden + type: timestamp + weight: 0 + region: content + settings: + date_format: medium + custom_date_format: '' + timezone: '' + third_party_settings: { } + field_media_test: + label: above + settings: + link_to_entity: true + third_party_settings: { } + type: string + weight: 6 + region: content + thumbnail: + type: image + weight: 5 + label: hidden + settings: + image_style: thumbnail + image_link: '' + region: content + third_party_settings: { } + uid: + label: hidden + type: author + weight: 0 + region: content + settings: { } + third_party_settings: { } +hidden: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_two.default.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_two.default.yml new file mode 100644 index 000000000000..a884fee4ce93 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/core.entity_view_display.media.type_two.default.yml @@ -0,0 +1,50 @@ +langcode: en +status: true +dependencies: + config: + - field.field.media.type_two.field_media_test_1 + - image.style.thumbnail + - media.type.type_two + module: + - image + - user +id: media.type_two.default +targetEntityType: media +bundle: type_two +mode: default +content: + created: + label: hidden + type: timestamp + weight: 0 + region: content + settings: + date_format: medium + custom_date_format: '' + timezone: '' + third_party_settings: { } + field_media_test_1: + label: above + settings: + link_to_entity: false + third_party_settings: { } + type: string + weight: 6 + region: content + thumbnail: + type: image + weight: 5 + label: hidden + settings: + image_style: thumbnail + image_link: '' + region: content + third_party_settings: { } + uid: + label: hidden + type: author + weight: 0 + region: content + settings: { } + third_party_settings: { } +hidden: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_one.field_media_test.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_one.field_media_test.yml new file mode 100644 index 000000000000..f19207c9e1d3 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_one.field_media_test.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_test + - media.type.type_one +id: media.type_one.field_media_test +field_name: field_media_test +entity_type: media +bundle: type_one +label: field_media_test +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_two.field_media_test_1.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_two.field_media_test_1.yml new file mode 100644 index 000000000000..5b093caf7bb3 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.field.media.type_two.field_media_test_1.yml @@ -0,0 +1,18 @@ +langcode: en +status: true +dependencies: + config: + - field.storage.media.field_media_test_1 + - media.type.type_two +id: media.type_two.field_media_test_1 +field_name: field_media_test_1 +entity_type: media +bundle: type_two +label: field_media_test_1 +description: '' +required: false +translatable: true +default_value: { } +default_value_callback: '' +settings: { } +field_type: string diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test.yml new file mode 100644 index 000000000000..40a77916ec7f --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - media +id: media.field_media_test +field_name: field_media_test +entity_type: media +type: string +settings: + max_length: 255 + is_ascii: false + case_sensitive: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test_1.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test_1.yml new file mode 100644 index 000000000000..73b11058e4f1 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/field.storage.media.field_media_test_1.yml @@ -0,0 +1,20 @@ +langcode: en +status: true +dependencies: + module: + - media +id: media.field_media_test_1 +field_name: field_media_test_1 +entity_type: media +type: string +settings: + max_length: 255 + is_ascii: false + case_sensitive: false +module: core +locked: false +cardinality: 1 +translatable: true +indexes: { } +persist_with_no_fields: false +custom_storage: false diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_one.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_one.yml new file mode 100644 index 000000000000..1f72b8beb6d1 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_one.yml @@ -0,0 +1,16 @@ +langcode: en +status: true +dependencies: + module: + - media + - media_test_source +id: type_one +label: 'Type One' +description: '' +source: test +queue_thumbnail_downloads: false +new_revision: false +source_configuration: + source_field: field_media_test + test_config_value: 'This is default value.' +field_map: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_two.yml b/core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_two.yml new file mode 100644 index 000000000000..13b549c6acb8 --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/config/install/media.type.type_two.yml @@ -0,0 +1,16 @@ +langcode: en +status: true +dependencies: + module: + - media + - media_test_source +id: type_two +label: 'Type Two' +description: '' +source: test +queue_thumbnail_downloads: false +new_revision: false +source_configuration: + source_field: field_media_test_1 + test_config_value: 'This is default value.' +field_map: { } diff --git a/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml b/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml new file mode 100644 index 000000000000..3ad1ae5f46ea --- /dev/null +++ b/core/modules/media_library/tests/modules/media_library_test/media_library_test.info.yml @@ -0,0 +1,10 @@ +name: 'Media library test' +type: module +description: 'Test module for Media library.' +package: Testing +core: 8.x +dependencies: + - drupal:media_library + - drupal:media_test_source + - drupal:menu_ui + - drupal:path diff --git a/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php new file mode 100644 index 000000000000..f8604e644bf9 --- /dev/null +++ b/core/modules/media_library/tests/src/FunctionalJavascript/MediaLibraryTest.php @@ -0,0 +1,102 @@ + [ + 'media_1', + 'media_2', + ], + 'type_two' => [ + 'media_3', + 'media_4', + ], + ]; + + foreach ($media as $type => $names) { + foreach ($names as $name) { + $entity = Media::create(['name' => $name, 'bundle' => $type]); + $source_field = $type === 'type_one' ? 'field_media_test' : 'field_media_test_1'; + $entity->set($source_field, $this->randomString()); + $entity->save(); + } + } + + // Create a user who can use the Media library. + $user = $this->drupalCreateUser([ + 'access administration pages', + 'access media overview', + 'create media', + 'delete any media', + 'view media', + ]); + $this->drupalLogin($user); + $this->drupalPlaceBlock('local_tasks_block'); + $this->drupalPlaceBlock('local_actions_block'); + } + + /** + * Tests that the Media library's administration page works as expected. + */ + public function testAdministrationPage() { + $assert_session = $this->assertSession(); + + // Visit the administration page. + $this->drupalGet('admin/content/media'); + + // Verify that the "Add media" link is present. + $assert_session->linkExists('Add media'); + + // Verify that media from two separate types is present. + $assert_session->pageTextContains('media_1'); + $assert_session->pageTextContains('media_3'); + + // Test that users can filter by type. + $this->getSession()->getPage()->selectFieldOption('Media type', 'Type One'); + $this->getSession()->getPage()->pressButton('Apply Filters'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextContains('media_2'); + $assert_session->pageTextNotContains('media_4'); + $this->getSession()->getPage()->selectFieldOption('Media type', 'Type Two'); + $this->getSession()->getPage()->pressButton('Apply Filters'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains('media_2'); + $assert_session->pageTextContains('media_4'); + + // Test that selecting elements as a part of bulk operations works. + $this->getSession()->getPage()->selectFieldOption('Media type', '- Any -'); + $this->getSession()->getPage()->pressButton('Apply Filters'); + $assert_session->assertWaitOnAjaxRequest(); + // This tests that anchor tags clicked inside the preview are suppressed. + $this->getSession()->executeScript('jQuery(".js-click-to-select__trigger a")[0].click()'); + $this->submitForm([], 'Apply to selected items'); + $assert_session->pageTextContains('media_1'); + $assert_session->pageTextNotContains('media_2'); + $this->submitForm([], 'Delete'); + $assert_session->pageTextNotContains('media_1'); + $assert_session->pageTextContains('media_2'); + } + +} From 41c3a88e35dcc1c5c628769264922aa1e13d98be Mon Sep 17 00:00:00 2001 From: webchick Date: Fri, 22 Jun 2018 12:13:40 -0700 Subject: [PATCH 16/39] Issue #2980864 by chr.fritsch, phenaproxima, marcoscano: Position of "published" checkbox varies by media type --- ...ntity_form_display.media.audio.default.yml | 8 ++--- ...ntity_form_display.media.video.default.yml | 8 ++--- .../tests/src/Functional/StandardTest.php | 34 +++++++++++++++++++ 3 files changed, 42 insertions(+), 8 deletions(-) diff --git a/core/profiles/standard/config/optional/core.entity_form_display.media.audio.default.yml b/core/profiles/standard/config/optional/core.entity_form_display.media.audio.default.yml index b3f5f6497f6d..28979932bff7 100644 --- a/core/profiles/standard/config/optional/core.entity_form_display.media.audio.default.yml +++ b/core/profiles/standard/config/optional/core.entity_form_display.media.audio.default.yml @@ -14,7 +14,7 @@ mode: default content: created: type: datetime_timestamp - weight: 3 + weight: 10 region: content settings: { } third_party_settings: { } @@ -35,7 +35,7 @@ content: third_party_settings: { } path: type: path - weight: 4 + weight: 30 region: content settings: { } third_party_settings: { } @@ -43,12 +43,12 @@ content: type: boolean_checkbox settings: display_label: true - weight: 5 + weight: 100 region: content third_party_settings: { } uid: type: entity_reference_autocomplete - weight: 2 + weight: 5 settings: match_operator: CONTAINS size: 60 diff --git a/core/profiles/standard/config/optional/core.entity_form_display.media.video.default.yml b/core/profiles/standard/config/optional/core.entity_form_display.media.video.default.yml index 658648fd183c..d0fa5049d5bf 100644 --- a/core/profiles/standard/config/optional/core.entity_form_display.media.video.default.yml +++ b/core/profiles/standard/config/optional/core.entity_form_display.media.video.default.yml @@ -14,7 +14,7 @@ mode: default content: created: type: datetime_timestamp - weight: 3 + weight: 10 region: content settings: { } third_party_settings: { } @@ -35,7 +35,7 @@ content: third_party_settings: { } path: type: path - weight: 4 + weight: 30 region: content settings: { } third_party_settings: { } @@ -43,12 +43,12 @@ content: type: boolean_checkbox settings: display_label: true - weight: 5 + weight: 100 region: content third_party_settings: { } uid: type: entity_reference_autocomplete - weight: 2 + weight: 5 settings: match_operator: CONTAINS size: 60 diff --git a/core/profiles/standard/tests/src/Functional/StandardTest.php b/core/profiles/standard/tests/src/Functional/StandardTest.php index 321c4bb61dd4..e91a192e2ba7 100644 --- a/core/profiles/standard/tests/src/Functional/StandardTest.php +++ b/core/profiles/standard/tests/src/Functional/StandardTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\standard\Functional; +use Drupal\Component\Utility\Html; +use Drupal\media\Entity\MediaType; use Drupal\Tests\SchemaCheckTestTrait; use Drupal\contact\Entity\ContactForm; use Drupal\Core\Url; @@ -219,6 +221,38 @@ public function testStandard() { $this->assertText('Archive'); $this->assertText('Restore to Draft'); $this->assertText('Restore'); + + \Drupal::service('module_installer')->install(['media']); + $role = Role::create([ + 'id' => 'admin_media', + 'label' => 'Admin media', + ]); + $role->grantPermission('administer media'); + $role->save(); + $this->adminUser->addRole($role->id()); + $this->adminUser->save(); + $assert_session = $this->assertSession(); + /** @var \Drupal\media\Entity\MediaType $media_type */ + foreach (MediaType::loadMultiple() as $media_type) { + $media_type_machine_name = $media_type->id(); + $this->drupalGet('media/add/' . $media_type_machine_name); + // Get the form element, and its HTML representation. + $form_selector = '#media-' . Html::cleanCssIdentifier($media_type_machine_name) . '-add-form'; + $form = $assert_session->elementExists('css', $form_selector); + $form_html = $form->getOuterHtml(); + + // The name field should come before the source field, which should itself + // come before the vertical tabs. + $name_field = $assert_session->fieldExists('Name', $form)->getOuterHtml(); + $test_source_field = $assert_session->fieldExists($media_type->getSource()->getSourceFieldDefinition($media_type)->getLabel(), $form)->getOuterHtml(); + $vertical_tabs = $assert_session->elementExists('css', '.form-type-vertical-tabs', $form)->getOuterHtml(); + $date_field = $assert_session->fieldExists('Date', $form)->getOuterHtml(); + $published_checkbox = $assert_session->fieldExists('Published', $form)->getOuterHtml(); + $this->assertTrue(strpos($form_html, $test_source_field) > strpos($form_html, $name_field)); + $this->assertTrue(strpos($form_html, $vertical_tabs) > strpos($form_html, $test_source_field)); + // The "Published" checkbox should be the last element. + $this->assertTrue(strpos($form_html, $published_checkbox) > strpos($form_html, $date_field)); + } } } From 1b66e5369fc70c4a755a852264e08df35ec4bda5 Mon Sep 17 00:00:00 2001 From: Lee Rowlands Date: Mon, 25 Jun 2018 13:37:08 +1000 Subject: [PATCH 17/39] Issue #2961822 by phenaproxima, tim.plunkett: Support object-based plugin definitions in ContextHandler --- .../ContextAwarePluginDefinitionInterface.php | 71 ++++++++++++++++ .../ContextAwarePluginDefinitionTrait.php | 60 ++++++++++++++ .../Core/Plugin/Context/ContextHandler.php | 36 ++++++-- .../Tests/Core/Plugin/ContextHandlerTest.php | 82 ++++++++++++++++--- 4 files changed, 230 insertions(+), 19 deletions(-) create mode 100644 core/lib/Drupal/Component/Plugin/Definition/ContextAwarePluginDefinitionInterface.php create mode 100644 core/lib/Drupal/Component/Plugin/Definition/ContextAwarePluginDefinitionTrait.php diff --git a/core/lib/Drupal/Component/Plugin/Definition/ContextAwarePluginDefinitionInterface.php b/core/lib/Drupal/Component/Plugin/Definition/ContextAwarePluginDefinitionInterface.php new file mode 100644 index 000000000000..d1ff95340c03 --- /dev/null +++ b/core/lib/Drupal/Component/Plugin/Definition/ContextAwarePluginDefinitionInterface.php @@ -0,0 +1,71 @@ +contextDefinitions); + } + + /** + * Implements \Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface::getContextDefinitions(). + */ + public function getContextDefinitions() { + return $this->contextDefinitions; + } + + /** + * Implements \Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface::getContextDefinition(). + */ + public function getContextDefinition($name) { + if ($this->hasContextDefinition($name)) { + return $this->contextDefinitions[$name]; + } + throw new ContextException($this->id() . " does not define a '$name' context"); + } + + /** + * Implements \Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface::addContextDefinition(). + */ + public function addContextDefinition($name, ContextDefinitionInterface $definition) { + $this->contextDefinitions[$name] = $definition; + return $this; + } + + /** + * Implements \Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface::removeContextDefinition(). + */ + public function removeContextDefinition($name) { + unset($this->contextDefinitions[$name]); + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Plugin/Context/ContextHandler.php b/core/lib/Drupal/Core/Plugin/Context/ContextHandler.php index f174d46170d5..c7517a9bdd0e 100644 --- a/core/lib/Drupal/Core/Plugin/Context/ContextHandler.php +++ b/core/lib/Drupal/Core/Plugin/Context/ContextHandler.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Plugin\Context; +use Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface; use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Exception\MissingValueContextException; use Drupal\Core\Cache\CacheableDependencyInterface; @@ -17,18 +18,37 @@ class ContextHandler implements ContextHandlerInterface { */ public function filterPluginDefinitionsByContexts(array $contexts, array $definitions) { return array_filter($definitions, function ($plugin_definition) use ($contexts) { - // If this plugin doesn't need any context, it is available to use. - // @todo Support object-based plugin definitions in - // https://www.drupal.org/project/drupal/issues/2961822. - if (!is_array($plugin_definition) || !isset($plugin_definition['context'])) { - return TRUE; - } + $context_definitions = $this->getContextDefinitions($plugin_definition); - // Check the set of contexts against the requirements. - return $this->checkRequirements($contexts, $plugin_definition['context']); + if ($context_definitions) { + // Check the set of contexts against the requirements. + return $this->checkRequirements($contexts, $context_definitions); + } + // If this plugin doesn't need any context, it is available to use. + return TRUE; }); } + /** + * Returns the context definitions associated with a plugin definition. + * + * @param array|\Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface $plugin_definition + * The plugin definition. + * + * @return \Drupal\Component\Plugin\Context\ContextDefinitionInterface[]|null + * The context definitions, or NULL if the plugin definition does not + * support contexts. + */ + protected function getContextDefinitions($plugin_definition) { + if ($plugin_definition instanceof ContextAwarePluginDefinitionInterface) { + return $plugin_definition->getContextDefinitions(); + } + if (is_array($plugin_definition) && isset($plugin_definition['context'])) { + return $plugin_definition['context']; + } + return NULL; + } + /** * {@inheritdoc} */ diff --git a/core/tests/Drupal/Tests/Core/Plugin/ContextHandlerTest.php b/core/tests/Drupal/Tests/Core/Plugin/ContextHandlerTest.php index 81a1ee9b7759..b4e1eb5e35fc 100644 --- a/core/tests/Drupal/Tests/Core/Plugin/ContextHandlerTest.php +++ b/core/tests/Drupal/Tests/Core/Plugin/ContextHandlerTest.php @@ -8,6 +8,9 @@ namespace Drupal\Tests\Core\Plugin; use Drupal\Component\Plugin\ConfigurablePluginInterface; +use Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionInterface; +use Drupal\Component\Plugin\Definition\ContextAwarePluginDefinitionTrait; +use Drupal\Component\Plugin\Definition\PluginDefinition; use Drupal\Component\Plugin\Exception\ContextException; use Drupal\Component\Plugin\Exception\MissingValueContextException; use Drupal\Core\Cache\NullBackend; @@ -209,49 +212,100 @@ public function providerTestFilterPluginDefinitionsByContexts() { // No context and no plugins, no plugins available. $data[] = [FALSE, $plugins, []]; - $plugins = ['expected_plugin' => []]; + $plugins = [ + 'expected_array_plugin' => [], + 'expected_object_plugin' => new ContextAwarePluginDefinition(), + ]; // No context, all plugins available. $data[] = [FALSE, $plugins, $plugins]; - $plugins = ['expected_plugin' => ['context' => []]]; + $plugins = [ + 'expected_array_plugin' => ['context' => []], + 'expected_object_plugin' => new ContextAwarePluginDefinition(), + ]; // No context, all plugins available. $data[] = [FALSE, $plugins, $plugins]; - $plugins = ['expected_plugin' => ['context' => ['context1' => new ContextDefinition('string')]]]; + $plugins = [ + 'expected_array_plugin' => [ + 'context' => ['context1' => new ContextDefinition('string')], + ], + 'expected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context1', new ContextDefinition('string')), + ]; // Missing context, no plugins available. $data[] = [FALSE, $plugins, []]; // Satisfied context, all plugins available. $data[] = [TRUE, $plugins, $plugins]; $mismatched_context_definition = (new ContextDefinition('expected_data_type'))->setConstraints(['mismatched_constraint_name' => 'mismatched_constraint_value']); - $plugins = ['expected_plugin' => ['context' => ['context1' => $mismatched_context_definition]]]; + $plugins = [ + 'expected_array_plugin' => [ + 'context' => ['context1' => $mismatched_context_definition], + ], + 'expected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context1', $mismatched_context_definition), + ]; // Mismatched constraints, no plugins available. $data[] = [TRUE, $plugins, []]; $optional_mismatched_context_definition = clone $mismatched_context_definition; $optional_mismatched_context_definition->setRequired(FALSE); - $plugins = ['expected_plugin' => ['context' => ['context1' => $optional_mismatched_context_definition]]]; + $plugins = [ + 'expected_array_plugin' => [ + 'context' => ['context1' => $optional_mismatched_context_definition], + ], + 'expected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context1', $optional_mismatched_context_definition), + ]; // Optional mismatched constraint, all plugins available. $data[] = [FALSE, $plugins, $plugins]; $expected_context_definition = (new ContextDefinition('string'))->setConstraints(['Blank' => []]); - $plugins = ['expected_plugin' => ['context' => ['context1' => $expected_context_definition]]]; + $plugins = [ + 'expected_array_plugin' => [ + 'context' => ['context1' => $expected_context_definition], + ], + 'expected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context1', $expected_context_definition), + ]; // Satisfied context with constraint, all plugins available. $data[] = [TRUE, $plugins, $plugins]; $optional_expected_context_definition = clone $expected_context_definition; $optional_expected_context_definition->setRequired(FALSE); - $plugins = ['expected_plugin' => ['context' => ['context1' => $optional_expected_context_definition]]]; + $plugins = [ + 'expected_array_plugin' => [ + 'context' => ['context1' => $optional_expected_context_definition], + ], + 'expected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context1', $optional_expected_context_definition), + ]; // Optional unsatisfied context, all plugins available. $data[] = [FALSE, $plugins, $plugins]; $unexpected_context_definition = (new ContextDefinition('unexpected_data_type'))->setConstraints(['mismatched_constraint_name' => 'mismatched_constraint_value']); $plugins = [ - 'unexpected_plugin' => ['context' => ['context1' => $unexpected_context_definition]], - 'expected_plugin' => ['context' => ['context2' => new ContextDefinition('string')]], + 'unexpected_array_plugin' => [ + 'context' => ['context1' => $unexpected_context_definition], + ], + 'expected_array_plugin' => [ + 'context' => ['context2' => new ContextDefinition('string')], + ], + 'unexpected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context1', $unexpected_context_definition), + 'expected_object_plugin' => (new ContextAwarePluginDefinition()) + ->addContextDefinition('context2', new ContextDefinition('string')), + ]; + // Context only satisfies two plugins. + $data[] = [ + TRUE, + $plugins, + [ + 'expected_array_plugin' => $plugins['expected_array_plugin'], + 'expected_object_plugin' => $plugins['expected_object_plugin'], + ], ]; - // Context only satisfies one plugin. - $data[] = [TRUE, $plugins, ['expected_plugin' => $plugins['expected_plugin']]]; return $data; } @@ -517,4 +571,10 @@ public function testApplyContextMappingConfigurableAssignedMiss() { } interface TestConfigurableContextAwarePluginInterface extends ContextAwarePluginInterface, ConfigurablePluginInterface { + +} + +class ContextAwarePluginDefinition extends PluginDefinition implements ContextAwarePluginDefinitionInterface { + use ContextAwarePluginDefinitionTrait; + } From cdbb68bb8c750a99b808a8e9349c28f8f415d1cf Mon Sep 17 00:00:00 2001 From: Lauri Eskola Date: Mon, 25 Jun 2018 13:13:43 +0300 Subject: [PATCH 18/39] Issue #2950158 by Vidushi Mehta, ankitjain28may, Shiva Srikanth T, ckrina, markconroy, Eli-T: Choose policy for defining font-weight on Umami theme --- core/profiles/demo_umami/themes/umami/css/base.css | 3 +-- .../themes/umami/css/components/blocks/banner/banner.css | 1 - .../umami/css/components/blocks/footer-promo/footer-promo.css | 4 ++-- .../content/highlighted-bottom/highlighted-bottom.css | 2 +- .../content/highlighted-medium/highlighted-medium.css | 2 +- .../content/highlighted-small/highlighted-small.css | 2 +- .../components/content/highlighted-top/highlighted-top.css | 2 +- .../themes/umami/css/components/fields/label-items.css | 2 +- .../css/components/navigation/menu-footer/menu-footer.css | 4 ++-- 9 files changed, 10 insertions(+), 12 deletions(-) diff --git a/core/profiles/demo_umami/themes/umami/css/base.css b/core/profiles/demo_umami/themes/umami/css/base.css index a63f96ef5d9e..1db4aeb1e56d 100644 --- a/core/profiles/demo_umami/themes/umami/css/base.css +++ b/core/profiles/demo_umami/themes/umami/css/base.css @@ -72,7 +72,6 @@ button, font-family: 'Scope One', Georgia, serif; font-size: 1.2rem; font-weight: 400; - font-weight: normal; transition: background-color 0.5s ease; } button:hover, @@ -219,7 +218,7 @@ label { color: #464646; display: block; font-size: 1rem; - font-weight: bold; + font-weight: 700; margin: 0.25rem 0; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/blocks/banner/banner.css b/core/profiles/demo_umami/themes/umami/css/components/blocks/banner/banner.css index 6e9567977ef5..45267bac6619 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/blocks/banner/banner.css +++ b/core/profiles/demo_umami/themes/umami/css/components/blocks/banner/banner.css @@ -35,7 +35,6 @@ font-family: 'Scope One', Georgia, serif; font-size: 1.2rem; font-weight: 400; - font-weight: normal; transition: background-color 0.5s ease; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css b/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css index 8ee83eb5cf45..8476f1a6a836 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css +++ b/core/profiles/demo_umami/themes/umami/css/components/blocks/footer-promo/footer-promo.css @@ -9,7 +9,7 @@ .block-type-footer-promo-block .block__title { font-size: 1.5rem; - font-weight: normal; + font-weight: 400; } .block-type-footer-promo-block .footer-promo-content { @@ -19,7 +19,7 @@ .block-type-footer-promo-block .footer-promo-content a { background-color: inherit; color: #fff; - font-weight: bold; + font-weight: 700; } .block-type-footer-promo-block .footer-promo-content a:active, diff --git a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-bottom/highlighted-bottom.css b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-bottom/highlighted-bottom.css index a7eb84f07adf..3f089a9997f5 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-bottom/highlighted-bottom.css +++ b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-bottom/highlighted-bottom.css @@ -13,7 +13,7 @@ } .node--view-mode-highlighted-bottom .node__title { - font-weight: normal; + font-weight: 400; margin-bottom: 1rem; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-medium/highlighted-medium.css b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-medium/highlighted-medium.css index e3c4367f8f63..b1a03cc394c7 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-medium/highlighted-medium.css +++ b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-medium/highlighted-medium.css @@ -24,7 +24,7 @@ } .node--view-mode-highlighted-medium .node__title { - font-weight: normal; + font-weight: 400; margin-bottom: 1rem; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-small/highlighted-small.css b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-small/highlighted-small.css index 741977482766..eefe796a2265 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-small/highlighted-small.css +++ b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-small/highlighted-small.css @@ -24,7 +24,7 @@ } .node--view-mode-highlighted-small .node__title { - font-weight: normal; + font-weight: 400; margin-bottom: 1rem; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-top/highlighted-top.css b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-top/highlighted-top.css index 9326d1e8a251..901c4319fe64 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-top/highlighted-top.css +++ b/core/profiles/demo_umami/themes/umami/css/components/content/highlighted-top/highlighted-top.css @@ -45,7 +45,7 @@ } .node--view-mode-highlighted-top .node__title { - font-weight: normal; + font-weight: 400; margin-bottom: 1rem; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/fields/label-items.css b/core/profiles/demo_umami/themes/umami/css/components/fields/label-items.css index d8e8b99bf2a9..9ed36b682fa9 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/fields/label-items.css +++ b/core/profiles/demo_umami/themes/umami/css/components/fields/label-items.css @@ -20,5 +20,5 @@ } .label-items .field__label { - font-weight: normal; + font-weight: 400; } diff --git a/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-footer/menu-footer.css b/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-footer/menu-footer.css index 3f10ec99ac66..7fc705472015 100644 --- a/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-footer/menu-footer.css +++ b/core/profiles/demo_umami/themes/umami/css/components/navigation/menu-footer/menu-footer.css @@ -9,7 +9,7 @@ .menu-footer__title { font-size: 1.5rem; - font-weight: normal; + font-weight: 400; } .menu-footer__item { @@ -18,7 +18,7 @@ .menu-footer .menu-footer__link { background-color: transparent; - font-weight: bold; + font-weight: 700; color: #fff; } From abc6c6a3362fb328e71847dbe8af8e5bc2db282e Mon Sep 17 00:00:00 2001 From: Alex Pott Date: Mon, 25 Jun 2018 11:25:06 +0100 Subject: [PATCH 19/39] Issue #2635046 by neclimdul, dawehner, alexpott: run-test.sh doesn't work in directories with spaces --- core/modules/simpletest/simpletest.module | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/modules/simpletest/simpletest.module b/core/modules/simpletest/simpletest.module index 4ace6e45948e..c76599504387 100644 --- a/core/modules/simpletest/simpletest.module +++ b/core/modules/simpletest/simpletest.module @@ -389,12 +389,12 @@ function simpletest_phpunit_command() { // The file in Composer's bin dir is a *nix link, which does not work when // extracted from a tarball and generally not on Windows. - $command = $vendor_dir . '/phpunit/phpunit/phpunit'; + $command = escapeshellarg($vendor_dir . '/phpunit/phpunit/phpunit'); if (substr(PHP_OS, 0, 3) == 'WIN') { // On Windows it is necessary to run the script using the PHP executable. $php_executable_finder = new PhpExecutableFinder(); $php = $php_executable_finder->find(); - $command = $php . ' -f ' . escapeshellarg($command) . ' --'; + $command = $php . ' -f ' . $command . ' --'; } return $command; } From 9e9ee75ba480f4d40b4cae4788d8e17743c2cba1 Mon Sep 17 00:00:00 2001 From: Alex Pott Date: Mon, 25 Jun 2018 12:01:36 +0100 Subject: [PATCH 20/39] Issue #2581557 by dawehner, mxh, xjm, sorabh.v6, JeroenT: Add ltrim($path, '/') in drupalGet method --- .../FunctionalTests/BrowserTestBaseTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php b/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php index 92a000201871..9673b6f06cc7 100644 --- a/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php +++ b/core/tests/Drupal/FunctionalTests/BrowserTestBaseTest.php @@ -67,6 +67,21 @@ public function testGoTo() { $this->assertSame('header value', $returned_header); } + /** + * Tests drupalGet(). + */ + public function testDrupalGet() { + $this->drupalGet('test-page'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('test-page'); + $this->drupalGet('/test-page'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('test-page'); + $this->drupalGet('/test-page/'); + $this->assertSession()->statusCodeEquals(200); + $this->assertSession()->addressEquals('/test-page/'); + } + /** * Tests basic form functionality. */ From 646584b3f718897179589f4f014531e3d9365331 Mon Sep 17 00:00:00 2001 From: Alex Pott Date: Mon, 25 Jun 2018 13:51:30 +0100 Subject: [PATCH 21/39] Issue #2946419 by vaplas, Lendude, Mile23: Entity: Convert system functional tests to phpunit --- .../src/Functional/FeedCacheTagsTest.php | 2 +- .../src/Functional/ItemCacheTagsTest.php | 2 +- .../Functional/BlockContentCacheTagsTest.php | 2 +- .../src/Functional}/CommentCacheTagsTest.php | 5 +- .../Update/ContentTranslationUpdateTest.php | 2 +- .../src/Functional/MediaCacheTagsTest.php | 2 +- .../src/Functional/NodeCacheTagsTest.php | 2 +- .../src/Functional/ShortcutCacheTagsTest.php | 2 +- .../Entity/EntityDefinitionTestTrait.php | 7 + .../Entity/EntityWithUriCacheTagsTestBase.php | 7 + .../src/Entity/EntityTestUpdate.php | 3 +- .../Entity/EntityCacheTagsTestBase.php | 2 +- .../src/Functional}/Entity/EntityFormTest.php | 6 +- .../Entity/EntityTranslationFormTest.php | 6 +- .../Entity/EntityWithUriCacheTagsTestBase.php | 149 +++++++++ .../Traits/EntityDefinitionTestTrait.php | 299 ++++++++++++++++++ ...ntEntityStorageSchemaConverterTestBase.php | 2 +- .../EntityUpdateAddRevisionDefaultTest.php | 2 +- ...dateAddRevisionTranslationAffectedTest.php | 2 +- ...UpdateToRevisionableAndPublishableTest.php | 2 +- .../src/Functional/TermCacheTagsTest.php | 2 +- .../src/Functional/UserCacheTagsTest.php | 2 +- ...sEntitySchemaSubscriberIntegrationTest.php | 2 +- .../Entity/EntityDefinitionUpdateTest.php | 2 +- 24 files changed, 489 insertions(+), 25 deletions(-) rename core/modules/comment/{src/Tests => tests/src/Functional}/CommentCacheTagsTest.php (96%) rename core/modules/system/{src/Tests => tests/src/Functional}/Entity/EntityFormTest.php (98%) rename core/modules/system/{src/Tests => tests/src/Functional}/Entity/EntityTranslationFormTest.php (97%) create mode 100644 core/modules/system/tests/src/Functional/Entity/EntityWithUriCacheTagsTestBase.php create mode 100644 core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php diff --git a/core/modules/aggregator/tests/src/Functional/FeedCacheTagsTest.php b/core/modules/aggregator/tests/src/Functional/FeedCacheTagsTest.php index dc0aab9263e7..0cf6d1d03121 100644 --- a/core/modules/aggregator/tests/src/Functional/FeedCacheTagsTest.php +++ b/core/modules/aggregator/tests/src/Functional/FeedCacheTagsTest.php @@ -3,7 +3,7 @@ namespace Drupal\Tests\aggregator\Functional; use Drupal\aggregator\Entity\Feed; -use Drupal\system\Tests\Entity\EntityWithUriCacheTagsTestBase; +use Drupal\Tests\system\Functional\Entity\EntityWithUriCacheTagsTestBase; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; diff --git a/core/modules/aggregator/tests/src/Functional/ItemCacheTagsTest.php b/core/modules/aggregator/tests/src/Functional/ItemCacheTagsTest.php index 09da3436827b..edd2ac32a92c 100644 --- a/core/modules/aggregator/tests/src/Functional/ItemCacheTagsTest.php +++ b/core/modules/aggregator/tests/src/Functional/ItemCacheTagsTest.php @@ -5,7 +5,7 @@ use Drupal\aggregator\Entity\Feed; use Drupal\aggregator\Entity\Item; use Drupal\Core\Cache\CacheBackendInterface; -use Drupal\system\Tests\Entity\EntityCacheTagsTestBase; +use Drupal\Tests\system\Functional\Entity\EntityCacheTagsTestBase; use Drupal\user\Entity\Role; use Drupal\user\RoleInterface; diff --git a/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php b/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php index 683500928508..ea5dd2873deb 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentCacheTagsTest.php @@ -7,7 +7,7 @@ use Drupal\Core\Cache\Cache; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Language\LanguageInterface; -use Drupal\system\Tests\Entity\EntityCacheTagsTestBase; +use Drupal\Tests\system\Functional\Entity\EntityCacheTagsTestBase; use Symfony\Component\HttpFoundation\Request; /** diff --git a/core/modules/comment/src/Tests/CommentCacheTagsTest.php b/core/modules/comment/tests/src/Functional/CommentCacheTagsTest.php similarity index 96% rename from core/modules/comment/src/Tests/CommentCacheTagsTest.php rename to core/modules/comment/tests/src/Functional/CommentCacheTagsTest.php index 12e0ef8451bc..0b0f4863afe7 100644 --- a/core/modules/comment/src/Tests/CommentCacheTagsTest.php +++ b/core/modules/comment/tests/src/Functional/CommentCacheTagsTest.php @@ -1,14 +1,15 @@ _view" + * - ":" + */ + public function testEntityUri() { + $entity_url = $this->entity->urlInfo(); + $entity_type = $this->entity->getEntityTypeId(); + + // Selects the view mode that will be used. + $view_mode = $this->selectViewMode($entity_type); + + // The default cache contexts for rendered entities. + $entity_cache_contexts = $this->getDefaultCacheContexts(); + + // Generate the standardized entity cache tags. + $cache_tag = $this->entity->getCacheTags(); + $view_cache_tag = \Drupal::entityManager()->getViewBuilder($entity_type)->getCacheTags(); + $render_cache_tag = 'rendered'; + + $this->pass("Test entity.", 'Debug'); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit, but also the presence of the correct cache tags. + $this->verifyPageCache($entity_url, 'HIT'); + + // Also verify the existence of an entity render cache entry, if this entity + // type supports render caching. + if (\Drupal::entityManager()->getDefinition($entity_type)->isRenderCacheable()) { + $cache_keys = ['entity_view', $entity_type, $this->entity->id(), $view_mode]; + $cid = $this->createCacheId($cache_keys, $entity_cache_contexts); + $redirected_cid = NULL; + $additional_cache_contexts = $this->getAdditionalCacheContextsForEntity($this->entity); + if (count($additional_cache_contexts)) { + $redirected_cid = $this->createCacheId($cache_keys, Cache::mergeContexts($entity_cache_contexts, $additional_cache_contexts)); + } + $expected_cache_tags = Cache::mergeTags($cache_tag, $view_cache_tag); + $expected_cache_tags = Cache::mergeTags($expected_cache_tags, $this->getAdditionalCacheTagsForEntity($this->entity)); + $expected_cache_tags = Cache::mergeTags($expected_cache_tags, [$render_cache_tag]); + $this->verifyRenderCache($cid, $expected_cache_tags, $redirected_cid); + } + + // Verify that after modifying the entity, there is a cache miss. + $this->pass("Test modification of entity.", 'Debug'); + $this->entity->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + // Verify that after modifying the entity's display, there is a cache miss. + $this->pass("Test modification of entity's '$view_mode' display.", 'Debug'); + $entity_display = entity_get_display($entity_type, $this->entity->bundle(), $view_mode); + $entity_display->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + if ($bundle_entity_type_id = $this->entity->getEntityType()->getBundleEntityType()) { + // Verify that after modifying the corresponding bundle entity, there is a + // cache miss. + $this->pass("Test modification of entity's bundle entity.", 'Debug'); + $bundle_entity = $this->container->get('entity_type.manager') + ->getStorage($bundle_entity_type_id) + ->load($this->entity->bundle()); + $bundle_entity->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + } + + if ($this->entity->getEntityType()->get('field_ui_base_route')) { + // Verify that after modifying a configurable field on the entity, there + // is a cache miss. + $this->pass("Test modification of entity's configurable field.", 'Debug'); + $field_storage_name = $this->entity->getEntityTypeId() . '.configurable_field'; + $field_storage = FieldStorageConfig::load($field_storage_name); + $field_storage->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + // Verify that after modifying a configurable field on the entity, there + // is a cache miss. + $this->pass("Test modification of entity's configurable field.", 'Debug'); + $field_name = $this->entity->getEntityTypeId() . '.' . $this->entity->bundle() . '.configurable_field'; + $field = FieldConfig::load($field_name); + $field->save(); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + } + + // Verify that after invalidating the entity's cache tag directly, there is + // a cache miss. + $this->pass("Test invalidation of entity's cache tag.", 'Debug'); + Cache::invalidateTags($this->entity->getCacheTagsToInvalidate()); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + // Verify that after invalidating the generic entity type's view cache tag + // directly, there is a cache miss. + $this->pass("Test invalidation of entity's 'view' cache tag.", 'Debug'); + Cache::invalidateTags($view_cache_tag); + $this->verifyPageCache($entity_url, 'MISS'); + + // Verify a cache hit. + $this->verifyPageCache($entity_url, 'HIT'); + + // Verify that after deleting the entity, there is a cache miss. + $this->pass('Test deletion of entity.', 'Debug'); + $this->entity->delete(); + $this->verifyPageCache($entity_url, 'MISS'); + $this->assertResponse(404); + } + + /** + * Gets the default cache contexts for rendered entities. + * + * @return array + * The default cache contexts for rendered entities. + */ + protected function getDefaultCacheContexts() { + return ['languages:' . LanguageInterface::TYPE_INTERFACE, 'theme', 'user.permissions']; + } + +} diff --git a/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php b/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php new file mode 100644 index 000000000000..e873e096f0dc --- /dev/null +++ b/core/modules/system/tests/src/Functional/Entity/Traits/EntityDefinitionTestTrait.php @@ -0,0 +1,299 @@ +state->set('entity_test_new', TRUE); + $this->entityManager->clearCachedDefinitions(); + $this->entityDefinitionUpdateManager->applyUpdates(); + } + + /** + * Resets the entity type definition. + */ + protected function resetEntityType() { + $this->state->set('entity_test_update.entity_type', NULL); + $this->entityManager->clearCachedDefinitions(); + $this->entityDefinitionUpdateManager->applyUpdates(); + } + + /** + * Updates the 'entity_test_update' entity type to revisionable. + */ + protected function updateEntityTypeToRevisionable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $keys = $entity_type->getKeys(); + $keys['revision'] = 'revision_id'; + $entity_type->set('entity_keys', $keys); + $entity_type->set('revision_table', 'entity_test_update_revision'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type not revisionable. + */ + protected function updateEntityTypeToNotRevisionable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $keys = $entity_type->getKeys(); + unset($keys['revision']); + $entity_type->set('entity_keys', $keys); + $entity_type->set('revision_table', NULL); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type to translatable. + */ + protected function updateEntityTypeToTranslatable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('translatable', TRUE); + $entity_type->set('data_table', 'entity_test_update_data'); + + if ($entity_type->isRevisionable()) { + $entity_type->set('revision_data_table', 'entity_test_update_revision_data'); + } + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type to not translatable. + */ + protected function updateEntityTypeToNotTranslatable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('translatable', FALSE); + $entity_type->set('data_table', NULL); + + if ($entity_type->isRevisionable()) { + $entity_type->set('revision_data_table', NULL); + } + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Updates the 'entity_test_update' entity type to revisionable and + * translatable. + */ + protected function updateEntityTypeToRevisionableAndTranslatable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $keys = $entity_type->getKeys(); + $keys['revision'] = 'revision_id'; + $entity_type->set('entity_keys', $keys); + $entity_type->set('translatable', TRUE); + $entity_type->set('data_table', 'entity_test_update_data'); + $entity_type->set('revision_table', 'entity_test_update_revision'); + $entity_type->set('revision_data_table', 'entity_test_update_revision_data'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Adds a new base field to the 'entity_test_update' entity type. + * + * @param string $type + * (optional) The field type for the new field. Defaults to 'string'. + * @param string $entity_type_id + * (optional) The entity type ID the base field should be attached to. + * Defaults to 'entity_test_update'. + * @param bool $is_revisionable + * (optional) If the base field should be revisionable or not. Defaults to + * FALSE. + */ + protected function addBaseField($type = 'string', $entity_type_id = 'entity_test_update', $is_revisionable = FALSE) { + $definitions['new_base_field'] = BaseFieldDefinition::create($type) + ->setName('new_base_field') + ->setRevisionable($is_revisionable) + ->setLabel(t('A new base field')); + $this->state->set($entity_type_id . '.additional_base_field_definitions', $definitions); + } + + /** + * Adds a long-named base field to the 'entity_test_update' entity type. + */ + protected function addLongNameBaseField() { + $key = 'entity_test_update.additional_base_field_definitions'; + $definitions = $this->state->get($key, []); + $definitions['new_long_named_entity_reference_base_field'] = BaseFieldDefinition::create('entity_reference') + ->setName('new_long_named_entity_reference_base_field') + ->setLabel(t('A new long-named base field')) + ->setSetting('target_type', 'user') + ->setSetting('handler', 'default'); + $this->state->set($key, $definitions); + } + + /** + * Adds a new revisionable base field to the 'entity_test_update' entity type. + * + * @param string $type + * (optional) The field type for the new field. Defaults to 'string'. + */ + protected function addRevisionableBaseField($type = 'string') { + $definitions['new_base_field'] = BaseFieldDefinition::create($type) + ->setName('new_base_field') + ->setLabel(t('A new revisionable base field')) + ->setRevisionable(TRUE); + $this->state->set('entity_test_update.additional_base_field_definitions', $definitions); + } + + /** + * Modifies the new base field from 'string' to 'text'. + */ + protected function modifyBaseField() { + $this->addBaseField('text'); + } + + /** + * Promotes a field to an entity key. + */ + protected function makeBaseFieldEntityKey() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + $entity_keys = $entity_type->getKeys(); + $entity_keys['new_base_field'] = 'new_base_field'; + $entity_type->set('entity_keys', $entity_keys); + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Removes the new base field from the 'entity_test_update' entity type. + * + * @param string $entity_type_id + * (optional) The entity type ID the base field should be attached to. + */ + protected function removeBaseField($entity_type_id = 'entity_test_update') { + $this->state->delete($entity_type_id . '.additional_base_field_definitions'); + } + + /** + * Adds a single-field index to the base field. + */ + protected function addBaseFieldIndex() { + $this->state->set('entity_test_update.additional_field_index.entity_test_update.new_base_field', TRUE); + } + + /** + * Removes the index added in addBaseFieldIndex(). + */ + protected function removeBaseFieldIndex() { + $this->state->delete('entity_test_update.additional_field_index.entity_test_update.new_base_field'); + } + + /** + * Adds a new bundle field to the 'entity_test_update' entity type. + * + * @param string $type + * (optional) The field type for the new field. Defaults to 'string'. + */ + protected function addBundleField($type = 'string') { + $definitions['new_bundle_field'] = FieldStorageDefinition::create($type) + ->setName('new_bundle_field') + ->setLabel(t('A new bundle field')) + ->setTargetEntityTypeId('entity_test_update'); + $this->state->set('entity_test_update.additional_field_storage_definitions', $definitions); + $this->state->set('entity_test_update.additional_bundle_field_definitions.test_bundle', $definitions); + } + + /** + * Modifies the new bundle field from 'string' to 'text'. + */ + protected function modifyBundleField() { + $this->addBundleField('text'); + } + + /** + * Removes the new bundle field from the 'entity_test_update' entity type. + */ + protected function removeBundleField() { + $this->state->delete('entity_test_update.additional_field_storage_definitions'); + $this->state->delete('entity_test_update.additional_bundle_field_definitions.test_bundle'); + } + + /** + * Adds an index to the 'entity_test_update' entity type's base table. + * + * @see \Drupal\entity_test\EntityTestStorageSchema::getEntitySchema() + */ + protected function addEntityIndex() { + $indexes = [ + 'entity_test_update__new_index' => ['name', 'test_single_property'], + ]; + $this->state->set('entity_test_update.additional_entity_indexes', $indexes); + } + + /** + * Removes the index added in addEntityIndex(). + */ + protected function removeEntityIndex() { + $this->state->delete('entity_test_update.additional_entity_indexes'); + } + + /** + * Renames the base table to 'entity_test_update_new'. + */ + protected function renameBaseTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('base_table', 'entity_test_update_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Renames the data table to 'entity_test_update_data_new'. + */ + protected function renameDataTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('data_table', 'entity_test_update_data_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Renames the revision table to 'entity_test_update_revision_new'. + */ + protected function renameRevisionBaseTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('revision_table', 'entity_test_update_revision_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Renames the revision data table to 'entity_test_update_revision_data_new'. + */ + protected function renameRevisionDataTable() { + $entity_type = clone $this->entityManager->getDefinition('entity_test_update'); + + $entity_type->set('revision_data_table', 'entity_test_update_revision_data_new'); + + $this->state->set('entity_test_update.entity_type', $entity_type); + } + + /** + * Removes the entity type. + */ + protected function deleteEntityType() { + $this->state->set('entity_test_update.entity_type', 'null'); + } + +} diff --git a/core/modules/system/tests/src/Functional/Entity/Update/SqlContentEntityStorageSchemaConverterTestBase.php b/core/modules/system/tests/src/Functional/Entity/Update/SqlContentEntityStorageSchemaConverterTestBase.php index a0acaef7571c..4a26f109140f 100644 --- a/core/modules/system/tests/src/Functional/Entity/Update/SqlContentEntityStorageSchemaConverterTestBase.php +++ b/core/modules/system/tests/src/Functional/Entity/Update/SqlContentEntityStorageSchemaConverterTestBase.php @@ -4,7 +4,7 @@ use Drupal\Core\Entity\Sql\TemporaryTableMapping; use Drupal\FunctionalTests\Update\UpdatePathTestBase; -use Drupal\system\Tests\Entity\EntityDefinitionTestTrait; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** * Defines a class for testing the conversion of entity types to revisionable. diff --git a/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionDefaultTest.php b/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionDefaultTest.php index e226540b6ae6..e696da67e81a 100644 --- a/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionDefaultTest.php +++ b/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionDefaultTest.php @@ -3,7 +3,7 @@ namespace Drupal\Tests\system\Functional\Update; use Drupal\FunctionalTests\Update\UpdatePathTestBase; -use Drupal\system\Tests\Entity\EntityDefinitionTestTrait; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** * Tests the upgrade path for adding the 'revision_default' field. diff --git a/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionTranslationAffectedTest.php b/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionTranslationAffectedTest.php index f7dcd0c41bfe..018c10f0517b 100644 --- a/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionTranslationAffectedTest.php +++ b/core/modules/system/tests/src/Functional/Update/EntityUpdateAddRevisionTranslationAffectedTest.php @@ -3,7 +3,7 @@ namespace Drupal\Tests\system\Functional\Update; use Drupal\FunctionalTests\Update\UpdatePathTestBase; -use Drupal\system\Tests\Entity\EntityDefinitionTestTrait; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** * Tests the upgrade path for adding the 'revision_translation_affected' field. diff --git a/core/modules/system/tests/src/Functional/Update/EntityUpdateToRevisionableAndPublishableTest.php b/core/modules/system/tests/src/Functional/Update/EntityUpdateToRevisionableAndPublishableTest.php index 2312dc6f0a67..e2206bed2688 100644 --- a/core/modules/system/tests/src/Functional/Update/EntityUpdateToRevisionableAndPublishableTest.php +++ b/core/modules/system/tests/src/Functional/Update/EntityUpdateToRevisionableAndPublishableTest.php @@ -4,7 +4,7 @@ use Drupal\Core\Field\BaseFieldDefinition; use Drupal\FunctionalTests\Update\UpdatePathTestBase; -use Drupal\system\Tests\Entity\EntityDefinitionTestTrait; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** * Tests the upgrade path for making an entity revisionable and publishable. diff --git a/core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php b/core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php index 35b7d6bd6e20..378d02c755f0 100644 --- a/core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php +++ b/core/modules/taxonomy/tests/src/Functional/TermCacheTagsTest.php @@ -2,9 +2,9 @@ namespace Drupal\Tests\taxonomy\Functional; -use Drupal\system\Tests\Entity\EntityWithUriCacheTagsTestBase; use Drupal\taxonomy\Entity\Vocabulary; use Drupal\taxonomy\Entity\Term; +use Drupal\Tests\system\Functional\Entity\EntityWithUriCacheTagsTestBase; /** * Tests the Taxonomy term entity's cache tags. diff --git a/core/modules/user/tests/src/Functional/UserCacheTagsTest.php b/core/modules/user/tests/src/Functional/UserCacheTagsTest.php index a9e98575b7b5..c9ab91fac71a 100644 --- a/core/modules/user/tests/src/Functional/UserCacheTagsTest.php +++ b/core/modules/user/tests/src/Functional/UserCacheTagsTest.php @@ -2,7 +2,7 @@ namespace Drupal\Tests\user\Functional; -use Drupal\system\Tests\Entity\EntityWithUriCacheTagsTestBase; +use Drupal\Tests\system\Functional\Entity\EntityWithUriCacheTagsTestBase; use Drupal\user\Entity\Role; use Drupal\user\Entity\User; use Drupal\user\RoleInterface; diff --git a/core/modules/views/tests/src/Kernel/EventSubscriber/ViewsEntitySchemaSubscriberIntegrationTest.php b/core/modules/views/tests/src/Kernel/EventSubscriber/ViewsEntitySchemaSubscriberIntegrationTest.php index 2319a7064a9e..24e4e6af5105 100644 --- a/core/modules/views/tests/src/Kernel/EventSubscriber/ViewsEntitySchemaSubscriberIntegrationTest.php +++ b/core/modules/views/tests/src/Kernel/EventSubscriber/ViewsEntitySchemaSubscriberIntegrationTest.php @@ -4,7 +4,7 @@ use Drupal\Core\Entity\EntityTypeEvent; use Drupal\Core\Entity\EntityTypeEvents; -use Drupal\system\Tests\Entity\EntityDefinitionTestTrait; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; use Drupal\Tests\views\Kernel\ViewsKernelTestBase; /** diff --git a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php index 4cbffedbf8c0..01044f724a1d 100644 --- a/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php +++ b/core/tests/Drupal/KernelTests/Core/Entity/EntityDefinitionUpdateTest.php @@ -15,7 +15,7 @@ use Drupal\Core\Field\FieldStorageDefinitionEvents; use Drupal\Core\Language\LanguageInterface; use Drupal\entity_test_update\Entity\EntityTestUpdate; -use Drupal\system\Tests\Entity\EntityDefinitionTestTrait; +use Drupal\Tests\system\Functional\Entity\Traits\EntityDefinitionTestTrait; /** * Tests EntityDefinitionUpdateManager functionality. From 6e8d9f1a45a39acdc0f8dfe89f84576ba672eecc Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Tue, 19 Jun 2018 15:24:36 -0400 Subject: [PATCH 22/39] Patch https://www.drupal.org/files/issues/2018-06-13/2957425-149_plus_2976334-33.patch --- .../Core/Access/AccessDependentInterface.php | 43 ++ .../Core/Access/AccessDependentTrait.php | 32 + .../block_content/block_content.install | 16 + .../block_content/block_content.module | 65 ++ .../block_content.post_update.php | 39 ++ .../optional/views.view.block_content.yml | 38 ++ .../src/BlockContentAccessControlHandler.php | 19 +- .../src/BlockContentInterface.php | 21 +- .../src/BlockContentListBuilder.php | 16 + .../src/BlockContentViewsData.php | 2 + .../block_content/src/Entity/BlockContent.php | 38 +- .../src/Plugin/Derivative/BlockContent.php | 2 +- .../src/Plugin/views/wizard/BlockContent.php | 35 + .../TestSelection.php | 82 +++ .../block_content_view_override.info.yml | 9 + .../install/views.view.block_content.yml | 604 ++++++++++++++++++ .../src/Functional/BlockContentListTest.php | 15 + .../Functional/BlockContentListViewsTest.php | 15 + .../Rest/BlockContentResourceTestBase.php | 5 + .../Update/BlockContentReusableUpdateTest.php | 131 ++++ .../Views/BlockContentWizardTest.php | 52 ++ .../src/Kernel/BlockContentDeriverTest.php | 66 ++ ...ockContentEntityReferenceSelectionTest.php | 166 +++++ .../config/schema/layout_builder.schema.yml | 17 + .../layout_builder/layout_builder.install | 74 +++ .../layout_builder/layout_builder.module | 44 ++ .../layout_builder.services.yml | 3 + .../layout_builder/src/EntityOperations.php | 335 ++++++++++ .../BlockComponentRenderArray.php | 13 + .../src/Form/RevertOverridesForm.php | 3 + .../src/InlineBlockContentUsage.php | 96 +++ .../Plugin/Block/InlineBlockContentBlock.php | 287 +++++++++ .../Derivative/InlineBlockContentDeriver.php | 57 ++ .../src/Functional/LayoutBuilderTest.php | 1 + .../InlineBlockContentBlockTest.php | 539 ++++++++++++++++ 35 files changed, 2974 insertions(+), 6 deletions(-) create mode 100644 core/lib/Drupal/Core/Access/AccessDependentInterface.php create mode 100644 core/lib/Drupal/Core/Access/AccessDependentTrait.php create mode 100644 core/modules/block_content/block_content.post_update.php create mode 100644 core/modules/block_content/src/Plugin/views/wizard/BlockContent.php create mode 100644 core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php create mode 100644 core/modules/block_content/tests/modules/block_content_view_override/block_content_view_override.info.yml create mode 100644 core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml create mode 100644 core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php create mode 100644 core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php create mode 100644 core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php create mode 100644 core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php create mode 100644 core/modules/layout_builder/src/EntityOperations.php create mode 100644 core/modules/layout_builder/src/InlineBlockContentUsage.php create mode 100644 core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php create mode 100644 core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php create mode 100644 core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php diff --git a/core/lib/Drupal/Core/Access/AccessDependentInterface.php b/core/lib/Drupal/Core/Access/AccessDependentInterface.php new file mode 100644 index 000000000000..a8386282e9b5 --- /dev/null +++ b/core/lib/Drupal/Core/Access/AccessDependentInterface.php @@ -0,0 +1,43 @@ +getAccessDependency()->access($op, $account, TRUE); + * @endcode + */ +interface AccessDependentInterface { + + /** + * Sets the access dependency. + * + * @param \Drupal\Core\Access\AccessibleInterface $access_dependency + * The object upon which access depends. + * + * @return $this + */ + public function setAccessDependency(AccessibleInterface $access_dependency); + + /** + * Gets the access dependency. + * + * @return \Drupal\Core\Access\AccessibleInterface|null + * The access dependency or NULL if none has been set. + */ + public function getAccessDependency(); + +} diff --git a/core/lib/Drupal/Core/Access/AccessDependentTrait.php b/core/lib/Drupal/Core/Access/AccessDependentTrait.php new file mode 100644 index 000000000000..94f2c93d958e --- /dev/null +++ b/core/lib/Drupal/Core/Access/AccessDependentTrait.php @@ -0,0 +1,32 @@ +accessDependency = $access_dependency; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getAccessDependency() { + return $this->accessDependency; + } + +} diff --git a/core/modules/block_content/block_content.install b/core/modules/block_content/block_content.install index e3da6bde881c..fab15b4f88c2 100644 --- a/core/modules/block_content/block_content.install +++ b/core/modules/block_content/block_content.install @@ -138,3 +138,19 @@ function block_content_update_8400() { $definition_update_manager->uninstallFieldStorageDefinition($content_translation_status); } } + +/** + * Add 'reusable' field to 'block_content' entities. + */ +function block_content_update_8600() { + $reusable = BaseFieldDefinition::create('boolean') + ->setLabel(t('Reusable')) + ->setDescription(t('A boolean indicating whether this block is reusable.')) + ->setTranslatable(FALSE) + ->setRevisionable(FALSE) + ->setDefaultValue(TRUE) + ->setInitialValue(TRUE); + + \Drupal::entityDefinitionUpdateManager() + ->installFieldStorageDefinition('reusable', 'block_content', 'block_content', $reusable); +} diff --git a/core/modules/block_content/block_content.module b/core/modules/block_content/block_content.module index 3adc979d9f08..71e1ee44d6ff 100644 --- a/core/modules/block_content/block_content.module +++ b/core/modules/block_content/block_content.module @@ -8,6 +8,9 @@ use Drupal\Core\Routing\RouteMatchInterface; use Drupal\field\Entity\FieldConfig; use Drupal\field\Entity\FieldStorageConfig; +use Drupal\Core\Database\Query\SelectInterface; +use Drupal\Core\Database\Query\AlterableInterface; +use Drupal\Core\Database\Query\ConditionInterface; /** * Implements hook_help(). @@ -105,3 +108,65 @@ function block_content_add_body_field($block_type_id, $label = 'Body') { return $field; } + +/** + * Implements hook_query_TAG_alter(). + * + * Alters any 'entity_reference' query where the entity type is + * 'block_content' and the query has the tag 'block_content_access'. + * + * These queries should only return reusable blocks unless a condition on + * reusable is explicitly set. + * + * Since block_content entities can be set to be non-reusable they should by + * default not be selectable as entity reference values. A module can still + * create a instance of + * \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface + * that will will allow selection of non-reusable blocks by explicitly setting + * a condition on the reusable field. + * + * @see \Drupal\block_content\BlockContentAccessControlHandler + */ +function block_content_query_entity_reference_alter(AlterableInterface $query) { + if ($query instanceof SelectInterface && $query->getMetaData('entity_type') === 'block_content' && $query->hasTag('block_content_access')) { + $data_table = \Drupal::entityTypeManager()->getDefinition('block_content')->getDataTable(); + if (array_key_exists($data_table, $query->getTables()) && !_block_content_has_reusable_condition($query->conditions())) { + // If no reusable condition create a condition set to TRUE. + $query->condition("$data_table.reusable", TRUE); + } + } +} + +/** + * Utility function to find nested conditions using the reusable field. + * + * @param array $condition + * The condition or condition group to check. + * + * @return bool + * Whether the conditions contain any condition using the reusable field. + */ +function _block_content_has_reusable_condition(array $condition) { + // If this is a condition group call this function recursively for each nested + // condition until a condition is found that return TRUE. + if (isset($condition['#conjunction'])) { + foreach (array_filter($condition, 'is_array') as $nested_condition) { + if (_block_content_has_reusable_condition($nested_condition)) { + return TRUE; + } + } + return FALSE; + } + if (isset($condition['field'])) { + $field = $condition['field']; + if (is_object($field) && $field instanceof ConditionInterface) { + return _block_content_has_reusable_condition($field->conditions()); + } + $field_parts = explode('.', $field); + $data_table = $data_table = \Drupal::entityTypeManager()->getDefinition('block_content')->getDataTable(); + // With nested conditions the data table may have a suffix at the end like + // 'block_content_field_data_2'. + return strpos($field_parts[0], $data_table) === 0 && $field_parts[1] === 'reusable'; + } + return FALSE; +} diff --git a/core/modules/block_content/block_content.post_update.php b/core/modules/block_content/block_content.post_update.php new file mode 100644 index 000000000000..927e588068cf --- /dev/null +++ b/core/modules/block_content/block_content.post_update.php @@ -0,0 +1,39 @@ +getDefinition('block_content') + ->getDataTable(); + + foreach ($config_factory->listAll('views.view.') as $view_config_name) { + $view = $config_factory->getEditable($view_config_name); + if ($view->get('base_table') != $data_table) { + continue; + } + foreach ($view->get('display') as $display_name => $display) { + // Update the default display and displays that have overridden filters. + if (!isset($display['display_options']['filters']['reusable']) && + ($display_name === 'default' || isset($display['display_options']['filters']))) { + // Save off the base part of the config path we are updating. + $base = "display.$display_name.display_options.filters.reusable"; + $view->set("$base.id", 'reusable') + ->set("$base.plugin_id", 'boolean') + ->set("$base.table", $data_table) + ->set("$base.field", "reusable") + ->set("$base.value", "1") + ->set("$base.entity_type", "block_content") + ->set("$base.entity_field", "reusable"); + } + } + $view->save(); + } +} diff --git a/core/modules/block_content/config/optional/views.view.block_content.yml b/core/modules/block_content/config/optional/views.view.block_content.yml index 1be5a0417c12..2c008864f32e 100644 --- a/core/modules/block_content/config/optional/views.view.block_content.yml +++ b/core/modules/block_content/config/optional/views.view.block_content.yml @@ -431,6 +431,44 @@ display: entity_type: block_content entity_field: type plugin_id: bundle + reusable: + id: reusable + table: block_content_field_data + field: reusable + relationship: none + group_type: group + admin_label: '' + operator: '=' + value: '1' + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: block_content + entity_field: reusable + plugin_id: boolean sorts: { } title: 'Custom block library' header: { } diff --git a/core/modules/block_content/src/BlockContentAccessControlHandler.php b/core/modules/block_content/src/BlockContentAccessControlHandler.php index 7079ef484951..2ad950f4d0c9 100644 --- a/core/modules/block_content/src/BlockContentAccessControlHandler.php +++ b/core/modules/block_content/src/BlockContentAccessControlHandler.php @@ -2,6 +2,7 @@ namespace Drupal\block_content; +use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityAccessControlHandler; @@ -19,10 +20,24 @@ class BlockContentAccessControlHandler extends EntityAccessControlHandler { */ protected function checkAccess(EntityInterface $entity, $operation, AccountInterface $account) { if ($operation === 'view') { - return AccessResult::allowedIf($entity->isPublished())->addCacheableDependency($entity) + $access = AccessResult::allowedIf($entity->isPublished())->addCacheableDependency($entity) ->orIf(AccessResult::allowedIfHasPermission($account, 'administer blocks')); } - return parent::checkAccess($entity, $operation, $account); + else { + $access = parent::checkAccess($entity, $operation, $account); + } + /** @var \Drupal\block_content\BlockContentInterface $entity */ + if ($entity->isReusable() === FALSE) { + if (!$entity instanceof AccessDependentInterface) { + throw new \LogicException("Non-reusable block entities must implement \Drupal\Core\Access\AccessDependentInterface for access control."); + } + $dependency = $entity->getAccessDependency(); + if (empty($dependency)) { + return AccessResult::forbidden("Non-reusable blocks must set an access dependency for access control."); + } + $access->andIf($dependency->access($operation, $account, TRUE))->addCacheableDependency($access); + } + return $access; } } diff --git a/core/modules/block_content/src/BlockContentInterface.php b/core/modules/block_content/src/BlockContentInterface.php index 75fdc5979b38..50fabdfb1ad0 100644 --- a/core/modules/block_content/src/BlockContentInterface.php +++ b/core/modules/block_content/src/BlockContentInterface.php @@ -2,6 +2,7 @@ namespace Drupal\block_content; +use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; use Drupal\Core\Entity\EntityPublishedInterface; @@ -10,7 +11,7 @@ /** * Provides an interface defining a custom block entity. */ -interface BlockContentInterface extends ContentEntityInterface, EntityChangedInterface, RevisionLogInterface, EntityPublishedInterface { +interface BlockContentInterface extends ContentEntityInterface, EntityChangedInterface, RevisionLogInterface, EntityPublishedInterface, AccessDependentInterface { /** * Returns the block revision log message. @@ -48,6 +49,24 @@ public function setInfo($info); */ public function setRevisionLog($revision_log); + /** + * Determines if the block is reusable or not. + * + * @return bool + * Returns TRUE if reusable and FALSE otherwise. + */ + public function isReusable(); + + /** + * Sets the block to be reusable. + * + * @param bool $reusable + * Whether the block should be reusable, defaults to TRUE. + * + * @return $this + */ + public function setReusable($reusable = TRUE); + /** * Sets the theme value. * diff --git a/core/modules/block_content/src/BlockContentListBuilder.php b/core/modules/block_content/src/BlockContentListBuilder.php index 7a4bdfc4c809..88545e09b4a9 100644 --- a/core/modules/block_content/src/BlockContentListBuilder.php +++ b/core/modules/block_content/src/BlockContentListBuilder.php @@ -28,4 +28,20 @@ public function buildRow(EntityInterface $entity) { return $row + parent::buildRow($entity); } + /** + * {@inheritdoc} + */ + protected function getEntityIds() { + $query = $this->getStorage()->getQuery() + ->sort($this->entityType->getKey('id')); + + $query->condition('reusable', TRUE); + + // Only add the pager if a limit is specified. + if ($this->limit) { + $query->pager($this->limit); + } + return $query->execute(); + } + } diff --git a/core/modules/block_content/src/BlockContentViewsData.php b/core/modules/block_content/src/BlockContentViewsData.php index 010ede0ea59a..e9ff0eb4cd83 100644 --- a/core/modules/block_content/src/BlockContentViewsData.php +++ b/core/modules/block_content/src/BlockContentViewsData.php @@ -23,6 +23,8 @@ public function getViewsData() { $data['block_content_field_data']['type']['field']['id'] = 'field'; + $data['block_content_field_data']['table']['wizard_id'] = 'block_content'; + $data['block_content']['block_content_listing_empty'] = [ 'title' => $this->t('Empty block library behavior'), 'help' => $this->t('Provides a link to add a new block.'), diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index 7696da091e03..2995e99cec10 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -2,6 +2,7 @@ namespace Drupal\block_content\Entity; +use Drupal\Core\Access\AccessDependentTrait; use Drupal\Core\Entity\EditorialContentEntityBase; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; @@ -77,6 +78,8 @@ */ class BlockContent extends EditorialContentEntityBase implements BlockContentInterface { + use AccessDependentTrait; + /** * The theme the block is being created in. * @@ -118,7 +121,9 @@ public function getTheme() { */ public function postSave(EntityStorageInterface $storage, $update = TRUE) { parent::postSave($storage, $update); - static::invalidateBlockPluginCache(); + if ($this->isReusable() || (isset($this->original) && $this->original->isReusable())) { + static::invalidateBlockPluginCache(); + } } /** @@ -126,7 +131,14 @@ public function postSave(EntityStorageInterface $storage, $update = TRUE) { */ public static function postDelete(EntityStorageInterface $storage, array $entities) { parent::postDelete($storage, $entities); - static::invalidateBlockPluginCache(); + /** @var \Drupal\block_content\BlockContentInterface $block */ + foreach ($entities as $block) { + if ($block->isReusable()) { + // If any deleted blocks are reusable clear the block cache. + static::invalidateBlockPluginCache(); + return; + } + } } /** @@ -200,6 +212,14 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setTranslatable(TRUE) ->setRevisionable(TRUE); + $fields['reusable'] = BaseFieldDefinition::create('boolean') + ->setLabel(t('Reusable')) + ->setDescription(t('A boolean indicating whether this block is reusable.')) + ->setTranslatable(FALSE) + ->setRevisionable(FALSE) + ->setDefaultValue(TRUE) + ->setInitialValue(TRUE); + return $fields; } @@ -282,6 +302,20 @@ public function setRevisionLogMessage($revision_log_message) { return $this; } + /** + * {@inheritdoc} + */ + public function isReusable() { + return (bool) $this->get('reusable')->value; + } + + /** + * {@inheritdoc} + */ + public function setReusable($reusable = TRUE) { + return $this->set('reusable', $reusable); + } + /** * Invalidates the block plugin cache after changes and deletions. */ diff --git a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php index ac82a6c39caa..ba1ab989688a 100644 --- a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php +++ b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php @@ -43,7 +43,7 @@ public static function create(ContainerInterface $container, $base_plugin_id) { * {@inheritdoc} */ public function getDerivativeDefinitions($base_plugin_definition) { - $block_contents = $this->blockContentStorage->loadMultiple(); + $block_contents = $this->blockContentStorage->loadByProperties(['reusable' => TRUE]); // Reset the discovered definitions. $this->derivatives = []; /** @var $block_content \Drupal\block_content\Entity\BlockContent */ diff --git a/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php b/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php new file mode 100644 index 000000000000..607250153746 --- /dev/null +++ b/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php @@ -0,0 +1,35 @@ + 'reusable', + 'plugin_id' => 'boolean', + 'table' => $this->base_table, + 'field' => 'reusable', + 'value' => '1', + 'entity_type' => $this->entityTypeId, + 'entity_field' => 'reusable', + ]; + return $filters; + } + +} diff --git a/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php b/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php new file mode 100644 index 000000000000..82b8aa90dc0d --- /dev/null +++ b/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php @@ -0,0 +1,82 @@ +testMode = $testMode; + } + + /** + * {@inheritdoc} + */ + protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') { + $query = parent::buildEntityQuery($match, $match_operator); + switch ($this->testMode) { + case 'reusable_condition_false': + $query->condition("reusable", 0); + break; + + case 'reusable_condition_exists': + $query->exists('reusable'); + break; + + case 'reusable_condition_group_false': + $group = $query->andConditionGroup() + ->condition("reusable", 0) + ->exists('type'); + $query->condition($group); + break; + + case 'reusable_condition_group_true': + $group = $query->andConditionGroup() + ->condition("reusable", 1) + ->exists('type'); + $query->condition($group); + break; + + case 'reusable_condition_nested_group_false': + $query->exists('type'); + $sub_group = $query->andConditionGroup() + ->condition("reusable", 0) + ->exists('type'); + $group = $query->andConditionGroup() + ->exists('type') + ->condition($sub_group); + $query->condition($group); + break; + + case 'reusable_condition_nested_group_true': + $query->exists('type'); + $sub_group = $query->andConditionGroup() + ->condition("reusable", 1) + ->exists('type'); + $group = $query->andConditionGroup() + ->exists('type') + ->condition($sub_group); + $query->condition($group); + break; + } + return $query; + } + +} diff --git a/core/modules/block_content/tests/modules/block_content_view_override/block_content_view_override.info.yml b/core/modules/block_content/tests/modules/block_content_view_override/block_content_view_override.info.yml new file mode 100644 index 000000000000..3ca2d1bc375d --- /dev/null +++ b/core/modules/block_content/tests/modules/block_content_view_override/block_content_view_override.info.yml @@ -0,0 +1,9 @@ +name: "Custom Block module reusable tests" +type: module +description: "Support module for custom block reusable testing." +package: Testing +version: VERSION +core: 8.x +dependencies: + - drupal:block_content + - drupal:views diff --git a/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml b/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml new file mode 100644 index 000000000000..a3cc40607794 --- /dev/null +++ b/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml @@ -0,0 +1,604 @@ +langcode: en +status: true +dependencies: + module: + - block_content + - user +_core: + default_config_hash: gkRJCqHr3uSO8ALHLatX-7YKfX0lWEgkC5qMBtCf_Sg +id: block_content +label: 'Custom block library' +module: views +description: 'Find and manage custom blocks.' +tag: default +base_table: block_content_field_data +base_field: id +core: 8.x +display: + default: + display_plugin: default + id: default + display_title: Master + position: 0 + display_options: + access: + type: perm + options: + perm: 'administer blocks' + cache: + type: tag + options: { } + query: + type: views_query + options: + disable_sql_rewrite: false + distinct: false + replica: false + query_comment: '' + query_tags: { } + exposed_form: + type: basic + options: + submit_button: Apply + reset_button: false + reset_button_label: Reset + exposed_sorts_label: 'Sort by' + expose_sort_order: true + sort_asc_label: Asc + sort_desc_label: Desc + pager: + type: mini + options: + items_per_page: 50 + offset: 0 + id: 0 + total_pages: null + tags: + previous: '‹ Previous' + next: 'Next ›' + expose: + items_per_page: false + items_per_page_label: 'Items per page' + items_per_page_options: '5, 10, 25, 50' + items_per_page_options_all: false + items_per_page_options_all_label: '- All -' + offset: false + offset_label: Offset + style: + type: table + options: + grouping: { } + row_class: '' + default_row_class: true + override: true + sticky: false + caption: '' + summary: '' + description: '' + columns: + info: info + type: type + changed: changed + operations: operations + info: + info: + sortable: true + default_sort_order: asc + align: '' + separator: '' + empty_column: false + responsive: '' + type: + sortable: true + default_sort_order: asc + align: '' + separator: '' + empty_column: false + responsive: '' + changed: + sortable: true + default_sort_order: desc + align: '' + separator: '' + empty_column: false + responsive: '' + operations: + sortable: false + default_sort_order: asc + align: '' + separator: '' + empty_column: false + responsive: '' + default: changed + empty_table: true + row: + type: fields + fields: + info: + id: info + table: block_content_field_data + field: info + relationship: none + group_type: group + admin_label: '' + label: 'Block description' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: value + type: string + settings: + link_to_entity: true + group_column: value + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: null + entity_field: info + plugin_id: field + type: + id: type + table: block_content_field_data + field: type + relationship: none + group_type: group + admin_label: '' + label: 'Block type' + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + click_sort_column: target_id + type: entity_reference_label + settings: + link: false + group_column: target_id + group_columns: { } + group_rows: true + delta_limit: 0 + delta_offset: 0 + delta_reversed: false + delta_first_last: false + multi_type: separator + separator: ', ' + field_api_classes: false + entity_type: block_content + entity_field: type + plugin_id: field + changed: + id: changed + table: block_content_field_data + field: changed + relationship: none + group_type: group + admin_label: '' + label: Updated + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + entity_type: block_content + entity_field: changed + type: timestamp + settings: + date_format: short + custom_date_format: '' + timezone: '' + plugin_id: field + operations: + id: operations + table: block_content + field: operations + relationship: none + group_type: group + admin_label: '' + label: Operations + exclude: false + alter: + alter_text: false + text: '' + make_link: false + path: '' + absolute: false + external: false + replace_spaces: false + path_case: none + trim_whitespace: false + alt: '' + rel: '' + link_class: '' + prefix: '' + suffix: '' + target: '' + nl2br: false + max_length: 0 + word_boundary: true + ellipsis: true + more_link: false + more_link_text: '' + more_link_path: '' + strip_tags: false + trim: false + preserve_tags: '' + html: false + element_type: '' + element_class: '' + element_label_type: '' + element_label_class: '' + element_label_colon: true + element_wrapper_type: '' + element_wrapper_class: '' + element_default_classes: true + empty: '' + hide_empty: false + empty_zero: false + hide_alter_empty: true + destination: true + entity_type: block_content + plugin_id: entity_operations + filters: + info: + id: info + table: block_content_field_data + field: info + relationship: none + group_type: group + admin_label: '' + operator: contains + value: '' + group: 1 + exposed: true + expose: + operator_id: info_op + label: 'Block description' + description: '' + use_operator: false + operator: info_op + identifier: info + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: block_content + entity_field: info + plugin_id: string + type: + id: type + table: block_content_field_data + field: type + relationship: none + group_type: group + admin_label: '' + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: type_op + label: 'Block type' + description: '' + use_operator: false + operator: type_op + identifier: type + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + reduce: false + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: block_content + entity_field: type + plugin_id: bundle + sorts: { } + title: 'Custom block library' + header: { } + footer: { } + empty: + area_text_custom: + id: area_text_custom + table: views + field: area_text_custom + relationship: none + group_type: group + admin_label: '' + empty: true + tokenize: false + content: 'There are no custom blocks available.' + plugin_id: text_custom + block_content_listing_empty: + admin_label: '' + empty: true + field: block_content_listing_empty + group_type: group + id: block_content_listing_empty + label: '' + relationship: none + table: block_content + plugin_id: block_content_listing_empty + entity_type: block_content + relationships: { } + arguments: { } + display_extenders: { } + cache_metadata: + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - user.permissions + max-age: -1 + tags: { } + page_1: + display_plugin: page + id: page_1 + display_title: Page + position: 1 + display_options: + display_extenders: { } + path: admin/structure/block/block-content + menu: + type: tab + title: 'Custom block library' + description: '' + parent: block.admin_display + weight: 0 + context: '0' + menu_name: admin + cache_metadata: + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - user.permissions + max-age: -1 + tags: { } + page_2: + display_plugin: page + id: page_2 + display_title: 'Page 2' + position: 2 + display_options: + display_extenders: { } + path: extra-view-display + filters: + type: + id: type + table: block_content_field_data + field: type + relationship: none + group_type: group + admin_label: '' + operator: in + value: { } + group: 1 + exposed: true + expose: + operator_id: type_op + label: 'Block type' + description: '' + use_operator: false + operator: type_op + identifier: type + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + anonymous: '0' + administrator: '0' + reduce: false + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: block_content + entity_field: type + plugin_id: bundle + info: + id: info + table: block_content_field_data + field: info + relationship: none + group_type: group + admin_label: '' + operator: 'contains' + value: block2 + group: 1 + exposed: false + expose: + operator_id: '' + label: '' + description: '' + use_operator: false + operator: '' + identifier: '' + required: false + remember: false + multiple: false + remember_roles: + authenticated: authenticated + placeholder: '' + is_grouped: false + group_info: + label: '' + description: '' + identifier: '' + optional: true + widget: select + multiple: false + remember: false + default_group: All + default_group_multiple: { } + group_items: { } + entity_type: block_content + entity_field: info + plugin_id: string + defaults: + filters: false + filter_groups: false + filter_groups: + operator: AND + groups: + 1: AND + cache_metadata: + max-age: -1 + contexts: + - 'languages:language_content' + - 'languages:language_interface' + - url + - url.query_args + - user.permissions + tags: { } diff --git a/core/modules/block_content/tests/src/Functional/BlockContentListTest.php b/core/modules/block_content/tests/src/Functional/BlockContentListTest.php index 9a26f1c40776..8919c05d0019 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentListTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentListTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\block_content\Functional; +use Drupal\block_content\Entity\BlockContent; + /** * Tests the listing of custom blocks. * @@ -104,6 +106,19 @@ public function testListing() { // Confirm that the empty text is displayed. $this->assertText(t('There are no custom blocks yet.')); + + $block_content = BlockContent::create([ + 'info' => 'Non-reusable block', + 'type' => 'basic', + 'reusable' => FALSE, + ]); + $block_content->save(); + + $this->drupalGet('admin/structure/block/block-content'); + // Confirm that the empty text is displayed. + $this->assertSession()->pageTextContains('There are no custom blocks yet.'); + // Confirm the non-reusable block is not on the page. + $this->assertSession()->pageTextNotContains('Non-reusable block'); } } diff --git a/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php b/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php index 1c623be82eba..f9cf29eb77bc 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php @@ -2,6 +2,8 @@ namespace Drupal\Tests\block_content\Functional; +use Drupal\block_content\Entity\BlockContent; + /** * Tests the Views-powered listing of custom blocks. * @@ -112,6 +114,19 @@ public function testListing() { // Confirm that the empty text is displayed. $this->assertText('There are no custom blocks available.'); $this->assertLink('custom block'); + + $block_content = BlockContent::create([ + 'info' => 'Non-reusable block', + 'type' => 'basic', + 'reusable' => FALSE, + ]); + $block_content->save(); + + $this->drupalGet('admin/structure/block/block-content'); + // Confirm that the empty text is displayed. + $this->assertSession()->pageTextContains('There are no custom blocks available.'); + // Confirm the non-reusable block is not on the page. + $this->assertSession()->pageTextNotContains('Non-reusable block'); } } diff --git a/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php b/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php index c77585eb292d..4a3ac11f4c5a 100644 --- a/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php +++ b/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php @@ -92,6 +92,11 @@ protected function getExpectedNormalizedEntity() { 'value' => 'en', ], ], + 'reusable' => [ + [ + 'value' => TRUE, + ], + ], 'type' => [ [ 'target_id' => 'basic', diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php new file mode 100644 index 000000000000..7803f40813d4 --- /dev/null +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php @@ -0,0 +1,131 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.4.0.bare.standard.php.gz', + ]; + } + + /** + * Tests adding a reusable field to the block content entity type. + * + * @see block_content_update_8600 + * @see block_content_post_update_add_views_reusable_filter + */ + public function testReusableFieldAddition() { + $assert_session = $this->assertSession(); + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + + // Delete custom block library view. + View::load('block_content')->delete(); + // Install the test module with the 'block_content' view with an extra + // display with overridden filters. This extra display should also have a + // filter added for 'reusable' field so that it does not expose non-reusable + // fields. This display also a filter only show blocks that contain + // 'block2' in the 'info' field. + $this->container->get('module_installer')->install(['block_content_view_override']); + + // Run updates. + $this->runUpdates(); + + // Check that the field exists and is configured correctly. + $reusable_field = $entity_definition_update_manager->getFieldStorageDefinition('reusable', 'block_content'); + $this->assertEquals('Reusable', $reusable_field->getLabel()); + $this->assertEquals('A boolean indicating whether this block is reusable.', $reusable_field->getDescription()); + $this->assertEquals(FALSE, $reusable_field->isRevisionable()); + $this->assertEquals(FALSE, $reusable_field->isTranslatable()); + + $after_block1 = BlockContent::create([ + 'info' => 'After update block1', + 'type' => 'basic_block', + ]); + $after_block1->save(); + // Add second block that will be shown with the 'info' filter on the + // additional view display. + $after_block2 = BlockContent::create([ + 'info' => 'After update block2', + 'type' => 'basic_block', + ]); + $after_block2->save(); + + $this->assertEquals(TRUE, $after_block1->isReusable()); + $this->assertEquals(TRUE, $after_block2->isReusable()); + + $non_reusable_block = BlockContent::create([ + 'info' => 'non-reusable block1', + 'type' => 'basic_block', + 'reusable' => FALSE, + ]); + $non_reusable_block->save(); + // Add second block that will be would shown with the 'info' filter on the + // additional view display if the 'reusable filter was not added. + $non_reusable_block2 = BlockContent::create([ + 'info' => 'non-reusable block2', + 'type' => 'basic_block', + 'reusable' => FALSE, + ]); + $non_reusable_block2->save(); + $this->assertEquals(FALSE, $non_reusable_block->isReusable()); + $this->assertEquals(FALSE, $non_reusable_block2->isReusable()); + + $admin_user = $this->drupalCreateUser(['administer blocks']); + $this->drupalLogin($admin_user); + + // Ensure the Custom Block view shows the reusable blocks but not + // the non-reusable block. + $this->drupalGet('admin/structure/block/block-content'); + $assert_session->statusCodeEquals('200'); + $assert_session->responseContains('view-id-block_content'); + $assert_session->pageTextContains($after_block1->label()); + $assert_session->pageTextContains($after_block2->label()); + $assert_session->pageTextNotContains($non_reusable_block->label()); + $assert_session->pageTextNotContains($non_reusable_block2->label()); + + // Ensure the views other display also filters out non-reusable blocks and + // still filters on the 'info' field. + $this->drupalGet('extra-view-display'); + $assert_session->statusCodeEquals('200'); + $assert_session->responseContains('view-id-block_content'); + $assert_session->pageTextNotContains($after_block1->label()); + $assert_session->pageTextContains($after_block2->label()); + $assert_session->pageTextNotContains($non_reusable_block->label()); + $assert_session->pageTextNotContains($non_reusable_block2->label()); + + $this->drupalGet('block/' . $after_block1->id()); + $assert_session->statusCodeEquals('200'); + + // Ensure that non-reusable blocks edit form edit route is not accessible. + $this->drupalGet('block/' . $non_reusable_block->id()); + $assert_session->statusCodeEquals('403'); + + // Ensure the Custom Block listing without Views installed shows the + // reusable blocks but not the non-reusable blocks. + // the non-reusable block. + $this->drupalGet('admin/structure/block/block-content'); + $this->container->get('module_installer')->uninstall(['views_ui', 'views']); + $this->drupalGet('admin/structure/block/block-content'); + $assert_session->statusCodeEquals('200'); + $assert_session->responseNotContains('view-id-block_content'); + $assert_session->pageTextContains($after_block1->label()); + $assert_session->pageTextContains($after_block2->label()); + $assert_session->pageTextNotContains($non_reusable_block->label()); + $assert_session->pageTextNotContains($non_reusable_block2->label()); + } + +} diff --git a/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php b/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php new file mode 100644 index 000000000000..2c59e5c50fe8 --- /dev/null +++ b/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php @@ -0,0 +1,52 @@ +drupalLogin($this->drupalCreateUser(['administer views'])); + $this->createBlockContentType('Basic block'); + } + + /** + * Tests creating a 'block_content' entity view. + */ + public function testViewAddBlockContent() { + $view = []; + $view['label'] = $this->randomMachineName(16); + $view['id'] = strtolower($this->randomMachineName(16)); + $view['description'] = $this->randomMachineName(16); + $view['page[create]'] = FALSE; + $view['show[wizard_key]'] = 'block_content'; + $this->drupalPostForm('admin/structure/views/add', $view, t('Save and edit')); + + $view_storage_controller = $this->container->get('entity_type.manager')->getStorage('view'); + /** @var \Drupal\views\Entity\View $view */ + $view = $view_storage_controller->load($view['id']); + + $display_options = $view->getDisplay('default')['display_options']; + + $this->assertEquals('block_content', $display_options['filters']['reusable']['entity_type']); + $this->assertEquals('reusable', $display_options['filters']['reusable']['entity_field']); + $this->assertEquals('boolean', $display_options['filters']['reusable']['plugin_id']); + $this->assertEquals('1', $display_options['filters']['reusable']['value']); + } + +} diff --git a/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php b/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php new file mode 100644 index 000000000000..600fc75fab97 --- /dev/null +++ b/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php @@ -0,0 +1,66 @@ +installSchema('system', ['sequence']); + $this->installEntitySchema('user'); + $this->installEntitySchema('block_content'); + } + + /** + * Tests that only reusable blocks are derived. + */ + public function testReusableBlocksOnlyAreDerived() { + // Create a block content type. + $block_content_type = BlockContentType::create([ + 'id' => 'spiffy', + 'label' => 'Mucho spiffy', + 'description' => "Provides a block type that increases your site's spiffiness by up to 11%", + ]); + $block_content_type->save(); + // And a block content entity. + $block_content = BlockContent::create([ + 'info' => 'Spiffy prototype', + 'type' => 'spiffy', + ]); + $block_content->save(); + + // Ensure the reusable block content is provided as a derivative block + // plugin. + /** @var \Drupal\Core\Block\BlockManagerInterface $block_manager */ + $block_manager = $this->container->get('plugin.manager.block'); + $plugin_id = 'block_content' . PluginBase::DERIVATIVE_SEPARATOR . $block_content->uuid(); + $this->assertTrue($block_manager->hasDefinition($plugin_id)); + + // Set the block not to be reusable. + $block_content->setReusable(FALSE); + $block_content->save(); + + // Ensure the non-reusable block content is not provided a derivative block + // plugin. + $this->assertFalse($block_manager->hasDefinition($plugin_id)); + } + +} diff --git a/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php b/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php new file mode 100644 index 000000000000..882e653eaf40 --- /dev/null +++ b/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php @@ -0,0 +1,166 @@ +installSchema('system', ['sequence']); + $this->installEntitySchema('user'); + $this->installEntitySchema('block_content'); + + // Create a block content type. + $block_content_type = BlockContentType::create([ + 'id' => 'spiffy', + 'label' => 'Mucho spiffy', + 'description' => "Provides a block type that increases your site's spiffiness by up to 11%", + ]); + $block_content_type->save(); + $this->entityTypeManager = $this->container->get('entity_type.manager'); + } + + /** + * Tests that non-reusable blocks are not referenceable entities. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Exception + */ + public function testReferenceableEntities() { + // And reusable and non-reusable block content entities. + $block_content_reusable = BlockContent::create([ + 'info' => 'Reusable Block', + 'type' => 'spiffy', + 'reusable' => TRUE, + ]); + $block_content_reusable->save(); + $block_content_nonreusable = BlockContent::create([ + 'info' => 'Non-reusable Block', + 'type' => 'spiffy', + 'reusable' => FALSE, + ]); + $block_content_nonreusable->save(); + + // Ensure that queries without all the tags are not altered. + $query = $this->entityTypeManager->getStorage('block_content')->getQuery(); + $this->assertCount(2, $query->execute()); + + $query = $this->entityTypeManager->getStorage('block_content')->getQuery(); + $query->addTag('block_content_access'); + $this->assertCount(2, $query->execute()); + + $query = $this->entityTypeManager->getStorage('block_content')->getQuery(); + $query->addTag('entity_query_block_content'); + $this->assertCount(2, $query->execute()); + + // Use \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection + // class to test that getReferenceableEntities() does not get the + // non-reusable entity. + $configuration = [ + 'target_type' => 'block_content', + 'target_bundles' => ['spiffy' => 'spiffy'], + 'sort' => ['field' => '_none'], + ]; + $selection_handler = new TestSelection($configuration, '', '', $this->container->get('entity.manager'), $this->container->get('module_handler'), \Drupal::currentUser()); + // Setup the 3 expectation cases. + $both_blocks = [ + 'spiffy' => [ + $block_content_reusable->id() => $block_content_reusable->label(), + $block_content_nonreusable->id() => $block_content_nonreusable->label(), + ], + ]; + $reusable_block = ['spiffy' => [$block_content_reusable->id() => $block_content_reusable->label()]]; + $non_reusable_block = ['spiffy' => [$block_content_nonreusable->id() => $block_content_nonreusable->label()]]; + + $this->assertEquals( + $reusable_block, + $selection_handler->getReferenceableEntities() + ); + + // Test various ways in which an EntityReferenceSelection plugin could set + // the 'reusable' condition. If the plugin has set a condition on 'reusable' + // at all then 'block_content_query_entity_reference_alter()' will not set + // a reusable condition. + $selection_handler->setTestMode('reusable_condition_false'); + $this->assertEquals( + $non_reusable_block, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode('reusable_condition_exists'); + $this->assertEquals( + $both_blocks, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode('reusable_condition_group_false'); + $this->assertEquals( + $non_reusable_block, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode('reusable_condition_group_true'); + $this->assertEquals( + $reusable_block, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode('reusable_condition_nested_group_false'); + $this->assertEquals( + $non_reusable_block, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode('reusable_condition_nested_group_true'); + $this->assertEquals( + $reusable_block, + $selection_handler->getReferenceableEntities() + ); + + // Change the block to reusable. + $block_content_nonreusable->setReusable(TRUE); + $block_content_nonreusable->save(); + // Don't use any conditions. + $selection_handler->setTestMode(NULL); + // Ensure that the block is now returned as a referenceable entity. + $this->assertEquals( + $both_blocks, + $selection_handler->getReferenceableEntities() + ); + } + +} diff --git a/core/modules/layout_builder/config/schema/layout_builder.schema.yml b/core/modules/layout_builder/config/schema/layout_builder.schema.yml index 682caa78c45e..287215c8e9fa 100644 --- a/core/modules/layout_builder/config/schema/layout_builder.schema.yml +++ b/core/modules/layout_builder/config/schema/layout_builder.schema.yml @@ -44,3 +44,20 @@ layout_builder.component: additional: type: ignore label: 'Additional data' + +inline_block_contents: + type: block_settings + label: 'Inline content block' + mapping: + view_mode: + type: string + lable: 'View mode' + block_revision_id: + type: integer + label: 'Block revision ID' + block_serialized: + type: string + label: 'Serialized block' + +block.settings.inline_block_content:*: + type: inline_block_contents diff --git a/core/modules/layout_builder/layout_builder.install b/core/modules/layout_builder/layout_builder.install index acb1e4fdf3d9..42cf53f67539 100644 --- a/core/modules/layout_builder/layout_builder.install +++ b/core/modules/layout_builder/layout_builder.install @@ -6,6 +6,8 @@ */ use Drupal\Core\Cache\Cache; +use Drupal\Core\Database\Database; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\Section; @@ -38,3 +40,75 @@ function layout_builder_install() { // prepare for future changes. Cache::invalidateTags(['rendered']); } + +/** + * Implements hook_schema(). + */ +function layout_builder_schema() { + $schema['inline_block_content_usage'] = [ + 'description' => 'Track where a block_content entity is used.', + 'fields' => [ + 'block_content_id' => [ + 'description' => 'The block_content entity ID.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'layout_entity_type' => [ + 'description' => 'The entity type of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => EntityTypeInterface::ID_MAX_LENGTH, + 'not null' => FALSE, + 'default' => '', + ], + 'layout_entity_id' => [ + 'description' => 'The ID of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => 128, + 'not null' => FALSE, + 'default' => 0, + ], + ], + 'primary key' => ['block_content_id'], + 'indexes' => [ + 'type_id' => ['layout_entity_type', 'layout_entity_id'], + ], + ]; + return $schema; +} + +/** + * Create the 'inline_block_content_usage' table. + */ +function layout_builder_update_8001() { + $inline_block_content_usage = [ + 'description' => 'Track where a entity is used.', + 'fields' => [ + 'block_content_id' => [ + 'description' => 'The block_content entity ID.', + 'type' => 'int', + 'unsigned' => TRUE, + 'not null' => TRUE, + ], + 'layout_entity_type' => [ + 'description' => 'The entity type of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => EntityTypeInterface::ID_MAX_LENGTH, + 'not null' => FALSE, + 'default' => '', + ], + 'layout_entity_id' => [ + 'description' => 'The ID of the parent entity.', + 'type' => 'varchar_ascii', + 'length' => 128, + 'not null' => FALSE, + 'default' => 0, + ], + ], + 'primary key' => ['block_content_id'], + 'indexes' => [ + 'type_id' => ['layout_entity_type', 'layout_entity_id'], + ], + ]; + Database::getConnection()->schema()->createTable('inline_block_content_usage', $inline_block_content_usage); +} diff --git a/core/modules/layout_builder/layout_builder.module b/core/modules/layout_builder/layout_builder.module index 2138a045f3f0..dd5af3bbdc3a 100644 --- a/core/modules/layout_builder/layout_builder.module +++ b/core/modules/layout_builder/layout_builder.module @@ -5,6 +5,7 @@ * Provides hook implementations for Layout Builder. */ +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Routing\RouteMatchInterface; use Drupal\Core\Url; @@ -12,6 +13,7 @@ use Drupal\field\FieldConfigInterface; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplayStorage; use Drupal\layout_builder\Form\LayoutBuilderEntityViewDisplayForm; +use Drupal\layout_builder\EntityOperations; /** * Implements hook_help(). @@ -81,3 +83,45 @@ function layout_builder_field_config_delete(FieldConfigInterface $field_config) $sample_entity_generator->delete($field_config->getTargetEntityTypeId(), $field_config->getTargetBundle()); \Drupal::service('plugin.manager.block')->clearCachedDefinitions(); } + +/** + * Implements hook_entity_presave(). + */ +function layout_builder_entity_presave(EntityInterface $entity) { + /** @var \Drupal\layout_builder\EntityOperations $entity_operations */ + $entity_operations = \Drupal::service('class_resolver')->getInstanceFromDefinition(EntityOperations::class); + $entity_operations->handlePreSave($entity); +} + +/** + * Implements hook_entity_delete(). + */ +function layout_builder_entity_delete(EntityInterface $entity) { + /** @var \Drupal\layout_builder\EntityOperations $entity_operations */ + $entity_operations = \Drupal::service('class_resolver')->getInstanceFromDefinition(EntityOperations::class); + $entity_operations->handleEntityDelete($entity); +} + +/** + * Implements hook_cron(). + */ +function layout_builder_cron() { + /** @var \Drupal\layout_builder\EntityOperations $entity_operations */ + $entity_operations = \Drupal::service('class_resolver')->getInstanceFromDefinition(EntityOperations::class); + $entity_operations->removeUnused(); +} + +/** + * Implements hook_plugin_filter_TYPE_alter(). + */ +function layout_builder_plugin_filter_block_alter(array &$definitions, array $extra, $consumer) { + // @todo Determine the 'inline_block_content' blocks should be allowe outside + // of layout_builder https://www.drupal.org/node/2979142. + if ($consumer !== 'layout_builder') { + foreach ($definitions as $id => $definition) { + if ($definition['id'] === 'inline_block_content') { + unset($definitions[$id]); + } + } + } +} diff --git a/core/modules/layout_builder/layout_builder.services.yml b/core/modules/layout_builder/layout_builder.services.yml index 4fe50929bd48..1f3a70e44e8b 100644 --- a/core/modules/layout_builder/layout_builder.services.yml +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -39,3 +39,6 @@ services: logger.channel.layout_builder: parent: logger.channel_base arguments: ['layout_builder'] + inline_block_content.usage: + class: Drupal\layout_builder\InlineBlockContentUsage + arguments: ['@database'] diff --git a/core/modules/layout_builder/src/EntityOperations.php b/core/modules/layout_builder/src/EntityOperations.php new file mode 100644 index 000000000000..b6466dfbdb8a --- /dev/null +++ b/core/modules/layout_builder/src/EntityOperations.php @@ -0,0 +1,335 @@ +hasDefinition('block_content')) { + $this->storage = $entityTypeManager->getStorage('block_content'); + } + $this->usage = $usage; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container) { + return new static( + $container->get('entity_type.manager'), + $container->get('inline_block_content.usage') + ); + } + + /** + * Remove all unused entities on save. + * + * Entities that were used in prevision revisions will be removed if not + * saving a new revision. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The parent entity. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function removeUnusedForEntityOnSave(EntityInterface $entity) { + // If the entity is new or '$entity->original' is not set then there will + // not be any unused inline blocks to remove. + if ($entity->isNew() || !isset($entity->original)) { + return; + } + $sections = $this->getEntitySections($entity); + // If this is a layout override and there are no sections then it is a new + // override. + if ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout') && empty($sections)) { + return; + } + // If this a new revision do not remove content_block entities. + if ($entity instanceof RevisionableInterface && $entity->isNewRevision()) { + return; + } + $original_sections = $this->getEntitySections($entity->original); + $current_revision_ids = $this->getInBlockRevisionIdsInSection($sections); + // If there are any revisions in the original that aren't current there may + // some blocks that need to be removed. + if ($original_revision_ids = array_diff($this->getInBlockRevisionIdsInSection($original_sections), $current_revision_ids)) { + if ($removed_ids = array_diff($this->getBlockIdsForRevisionIds($original_revision_ids), $this->getBlockIdsForRevisionIds($current_revision_ids))) { + $this->deleteBlocksAndUsage($removed_ids); + } + } + } + + /** + * Handles entity tracking on deleting a parent entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The parent entity. + */ + public function handleEntityDelete(EntityInterface $entity) { + if ($this->isStorageAvailable() && $this->isLayoutCompatibleEntity($entity)) { + $this->usage->removeByLayoutEntity($entity); + } + } + + /** + * Gets the sections for an entity if any. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity. + * + * @return \Drupal\layout_builder\Section[]|null + * The entity layout sections if available. + * + * @internal + */ + protected function getEntitySections(EntityInterface $entity) { + if ($entity->getEntityTypeId() === 'entity_view_display' && $entity instanceof LayoutBuilderEntityViewDisplay) { + return $entity->getSections(); + } + elseif ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')) { + return $entity->get('layout_builder__layout')->getSections(); + } + return NULL; + } + + /** + * Handles saving a parent entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The parent entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityStorageException + * @throws \Exception + */ + public function handlePreSave(EntityInterface $entity) { + if (!$this->isStorageAvailable() || !$this->isLayoutCompatibleEntity($entity)) { + return; + } + $duplicate_blocks = FALSE; + + if ($sections = $this->getEntitySections($entity)) { + if ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')) { + if (!$entity->isNew() && isset($entity->original)) { + /** @var \Drupal\layout_builder\Field\LayoutSectionItemList $original_sections_field */ + $original_sections_field = $entity->original->get('layout_builder__layout'); + if ($original_sections_field->isEmpty()) { + // @todo Is there a better way to tell if Layout Override is new? + // what if is overridden and all sections removed. Currently if you + // remove all sections from an override it reverts to the default. + // Is that a feature or a bug? + $duplicate_blocks = TRUE; + } + } + } + $new_revision = FALSE; + if ($entity instanceof RevisionableInterface) { + // If the parent entity will have a new revision create a new revision + // of the block. + // @todo Currently revisions are not actually created. + // @see https://www.drupal.org/node/2937199 + // To bypass this always make a revision because the parent entity is + // instance of RevisionableInterface. After the issue is fixed only + // create a new revision if '$entity->isNewRevision()'. + $new_revision = TRUE; + } + + foreach ($this->getInlineBlockComponents($sections) as $component) { + /** @var \Drupal\layout_builder\Plugin\Block\InlineBlockContentBlock $plugin */ + $plugin = $component->getPlugin(); + $pre_save_configuration = $plugin->getConfiguration(); + $plugin->saveBlockContent($new_revision, $duplicate_blocks); + $post_save_configuration = $plugin->getConfiguration(); + if ($duplicate_blocks || (empty($pre_save_configuration['block_revision_id']) && !empty($post_save_configuration['block_revision_id']))) { + $this->usage->addUsage($this->getPluginBlockId($plugin), $entity->getEntityTypeId(), $entity->id()); + } + $component->setConfiguration($plugin->getConfiguration()); + } + } + $this->removeUnusedForEntityOnSave($entity); + } + + /** + * Gets components that have Inline Block plugins. + * + * @param \Drupal\layout_builder\Section[] $sections + * The layout sections. + * + * @return \Drupal\layout_builder\SectionComponent[] + * The components that contain Inline Block plugins. + */ + protected function getInlineBlockComponents(array $sections) { + $inline_components = []; + foreach ($sections as $section) { + $components = $section->getComponents(); + + foreach ($components as $component) { + $plugin = $component->getPlugin(); + if ($plugin instanceof InlineBlockContentBlock) { + $inline_components[] = $component; + } + } + } + return $inline_components; + } + + /** + * Determines if an entity can have a layout. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The entity to check. + * + * @return bool + * TRUE if the entity can have a layout otherwise FALSE. + */ + protected function isLayoutCompatibleEntity(EntityInterface $entity) { + return ($entity->getEntityTypeId() === 'entity_view_display' && $entity instanceof LayoutBuilderEntityViewDisplay) || + ($entity instanceof FieldableEntityInterface && $entity->hasField('layout_builder__layout')); + } + + /** + * Gets a block ID for a inline block content plugin. + * + * @param \Drupal\Component\Plugin\PluginInspectionInterface $plugin + * The inline block content plugin. + * + * @return int + * The block content ID or null none available. + */ + protected function getPluginBlockId(PluginInspectionInterface $plugin) { + /** @var \Drupal\Component\Plugin\ConfigurablePluginInterface $plugin */ + $configuration = $plugin->getConfiguration(); + if (!empty($configuration['block_revision_id'])) { + $query = $this->storage->getQuery(); + $query->condition('revision_id', $configuration['block_revision_id']); + return array_values($query->execute())[0]; + } + return NULL; + } + + /** + * Delete the content blocks and delete the usage records. + * + * @param int[] $block_content_ids + * The block content entity IDs. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + protected function deleteBlocksAndUsage(array $block_content_ids) { + foreach ($block_content_ids as $block_content_id) { + if ($block = $this->storage->load($block_content_id)) { + $block->delete(); + } + } + $this->usage->deleteUsage($block_content_ids); + } + + /** + * Removes unused block content entities. + * + * @param int $limit + * The maximum number of block content entities to remove. + * + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function removeUnused($limit = 100) { + if ($this->isStorageAvailable()) { + $this->deleteBlocksAndUsage($this->usage->getUnused($limit)); + } + } + + /** + * The block_content entity storage is available. + * + * If the 'block_content' module is not enable this the public methods on this + * class should not execute their operations. + * + * @return bool + * Whether the 'block_content' storage is available. + */ + protected function isStorageAvailable() { + return !empty($this->storage); + } + + /** + * Gets revision IDs for layout sections. + * + * @param \Drupal\layout_builder\Section[] $sections + * The layout sections. + * + * @return int[] + * The revision IDs. + */ + protected function getInBlockRevisionIdsInSection(array $sections) { + $revision_ids = []; + foreach ($this->getInlineBlockComponents($sections) as $component) { + $configuration = $component->getPlugin()->getConfiguration(); + if (!empty($configuration['block_revision_id'])) { + $revision_ids[] = $configuration['block_revision_id']; + } + } + return $revision_ids; + } + + /** + * Gets blocks IDs for an array of revision IDs. + * + * @param int[] $revision_ids + * The revision IDs. + * + * @return int[] + * The block IDs. + */ + protected function getBlockIdsForRevisionIds(array $revision_ids) { + if ($revision_ids) { + $query = $this->storage->getQuery(); + $query->condition('revision_id', $revision_ids, 'IN'); + $block_ids = $query->execute(); + return $block_ids; + } + return []; + + } + +} diff --git a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php index 181ed8229b6c..fc3818bf3109 100644 --- a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php +++ b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php @@ -2,6 +2,7 @@ namespace Drupal\layout_builder\EventSubscriber; +use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Session\AccountInterface; @@ -56,6 +57,18 @@ public function onBuildRender(SectionComponentBuildRenderArrayEvent $event) { return; } + // Set block access dependency even if we are not checking access on + // this level. The block itself may render another AccessDependentInterface + // object and need to pass on this value. + if ($block instanceof AccessDependentInterface) { + $contexts = $event->getContexts(); + if (isset($contexts['layout_builder.entity'])) { + if ($entity = $contexts['layout_builder.entity']->getContextValue()) { + $block->setAccessDependency($entity); + } + } + } + // Only check access if the component is not being previewed. if ($event->inPreview()) { $access = AccessResult::allowed()->setCacheMaxAge(0); diff --git a/core/modules/layout_builder/src/Form/RevertOverridesForm.php b/core/modules/layout_builder/src/Form/RevertOverridesForm.php index b6d07d90890c..cf7d91844d17 100644 --- a/core/modules/layout_builder/src/Form/RevertOverridesForm.php +++ b/core/modules/layout_builder/src/Form/RevertOverridesForm.php @@ -103,6 +103,9 @@ public function buildForm(array $form, FormStateInterface $form_state, SectionSt * {@inheritdoc} */ public function submitForm(array &$form, FormStateInterface $form_state) { + // @todo Remove this quick fix after https://www.drupal.org/node/2970801 + $this->sectionStorage = \Drupal::service('plugin.manager.layout_builder.section_storage')->loadFromStorageId($this->sectionStorage->getStorageType(), $this->sectionStorage->getStorageId()); + // Remove all sections. while ($this->sectionStorage->count()) { $this->sectionStorage->removeSection(0); diff --git a/core/modules/layout_builder/src/InlineBlockContentUsage.php b/core/modules/layout_builder/src/InlineBlockContentUsage.php new file mode 100644 index 000000000000..9166a79c0d42 --- /dev/null +++ b/core/modules/layout_builder/src/InlineBlockContentUsage.php @@ -0,0 +1,96 @@ +connection = $connection; + } + + /** + * Add a usage record. + * + * @param int $block_content_id + * The block content id. + * @param string $layout_entity_type + * The layout entity type. + * @param string $layout_entity_id + * The layout entity id. + * + * @throws \Exception + */ + public function addUsage($block_content_id, $layout_entity_type, $layout_entity_id) { + $this->connection->merge('inline_block_content_usage') + ->keys([ + 'block_content_id' => $block_content_id, + 'layout_entity_id' => $layout_entity_id, + 'layout_entity_type' => $layout_entity_type, + ])->execute(); + } + + /** + * Gets unused inline block content IDs. + * + * @param int $limit + * The maximum number of block content entity IDs to return. + * + * @return int[] + * The entity IDs. + */ + public function getUnused($limit = 100) { + $query = $this->connection->select('inline_block_content_usage', 't'); + $query->fields('t', ['block_content_id']); + $query->isNull('layout_entity_id'); + $query->isNull('layout_entity_type'); + return $query->range(0, $limit)->execute()->fetchCol(); + } + + /** + * Remove usage record by layout entity. + * + * @param \Drupal\Core\Entity\EntityInterface $entity + * The layout entity. + */ + public function removeByLayoutEntity(EntityInterface $entity) { + $query = $this->connection->update('inline_block_content_usage') + ->fields([ + 'layout_entity_type' => NULL, + 'layout_entity_id' => NULL, + ]); + $query->condition('layout_entity_type', $entity->getEntityTypeId()); + $query->condition('layout_entity_id', $entity->id()); + $query->execute(); + } + + /** + * Delete the content blocks and delete the usage records. + * + * @param int[] $block_content_ids + * The block content entity IDs. + */ + public function deleteUsage(array $block_content_ids) { + $query = $this->connection->delete('inline_block_content_usage')->condition('block_content_id', $block_content_ids, 'IN'); + $query->execute(); + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php new file mode 100644 index 000000000000..9ab2d26ec3a1 --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php @@ -0,0 +1,287 @@ +entityTypeManager = $entity_type_manager; + $this->entityDisplayRepository = $entity_display_repository; + if (!empty($this->configuration['block_revision_id']) || !empty($this->configuration['block_serialized'])) { + $this->isNew = FALSE; + } + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, array $configuration, $plugin_id, $plugin_definition) { + return new static( + $configuration, + $plugin_id, + $plugin_definition, + $container->get('entity_type.manager'), + $container->get('entity_display.repository') + ); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'view_mode' => 'full', + 'block_revision_id' => NULL, + 'block_serialized' => NULL, + ]; + } + + /** + * {@inheritdoc} + */ + public function blockForm($form, FormStateInterface $form_state) { + $block = $this->getEntity(); + + // Add the entity form display in a process callback so that #parents can + // be successfully propagated to field widgets. + $form['block_form'] = [ + '#type' => 'container', + '#process' => [[static::class, 'processBlockForm']], + '#block' => $block, + ]; + + $options = $this->entityDisplayRepository->getViewModeOptionsByBundle('block_content', $block->bundle()); + + $form['view_mode'] = [ + '#type' => 'select', + '#options' => $options, + '#title' => $this->t('View mode'), + '#description' => $this->t('The view mode in which to render the block.'), + '#default_value' => $this->configuration['view_mode'], + '#access' => count($options) > 1, + ]; + return $form; + } + + /** + * Process callback to insert a Custom Block form. + * + * @param array $element + * The containing element. + * @param \Drupal\Core\Form\FormStateInterface $form_state + * The form state. + * + * @return array + * The containing element, with the Custom Block form inserted. + */ + public static function processBlockForm(array $element, FormStateInterface $form_state) { + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $element['#block']; + EntityFormDisplay::collectRenderDisplay($block, 'edit')->buildForm($block, $element, $form_state); + $element['revision_log']['#access'] = FALSE; + $element['info']['#access'] = FALSE; + return $element; + } + + /** + * {@inheritdoc} + */ + public function blockValidate($form, FormStateInterface $form_state) { + $block_form = $form['block_form']; + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $block_form['#block']; + $form_display = EntityFormDisplay::collectRenderDisplay($block, 'edit'); + $complete_form_state = $form_state instanceof SubformStateInterface ? $form_state->getCompleteFormState() : $form_state; + $form_display->extractFormValues($block, $block_form, $complete_form_state); + $form_display->validateFormValues($block, $block_form, $complete_form_state); + // @todo Remove when https://www.drupal.org/project/drupal/issues/2948549 is closed. + $form_state->setTemporaryValue('block_form_parents', $block_form['#parents']); + } + + /** + * {@inheritdoc} + */ + public function blockSubmit($form, FormStateInterface $form_state) { + $this->configuration['view_mode'] = $form_state->getValue('view_mode'); + + // @todo Remove when https://www.drupal.org/project/drupal/issues/2948549 is closed. + $block_form = NestedArray::getValue($form, $form_state->getTemporaryValue('block_form_parents')); + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $block_form['#block']; + $form_display = EntityFormDisplay::collectRenderDisplay($block, 'edit'); + $complete_form_state = ($form_state instanceof SubformStateInterface) ? $form_state->getCompleteFormState() : $form_state; + $form_display->extractFormValues($block, $block_form, $complete_form_state); + $block->setInfo($this->configuration['label']); + $this->configuration['block_serialized'] = serialize($block); + } + + /** + * {@inheritdoc} + */ + protected function blockAccess(AccountInterface $account) { + if ($this->getEntity()) { + return $this->getEntity()->access('view', $account, TRUE); + } + return AccessResult::forbidden(); + } + + /** + * {@inheritdoc} + */ + public function build() { + $block = $this->getEntity(); + return $this->entityTypeManager->getViewBuilder($block->getEntityTypeId())->view($block, $this->configuration['view_mode']); + } + + /** + * Loads or creates the block content entity of the block. + * + * @return \Drupal\block_content\BlockContentInterface + * The block content entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + */ + protected function getEntity() { + if (!isset($this->blockContent)) { + if (!empty($this->configuration['block_serialized'])) { + $this->blockContent = unserialize($this->configuration['block_serialized']); + } + elseif (!empty($this->configuration['block_revision_id'])) { + $entity = $this->entityTypeManager->getStorage('block_content')->loadRevision($this->configuration['block_revision_id']); + $this->blockContent = $entity; + } + else { + $this->blockContent = $this->entityTypeManager->getStorage('block_content')->create([ + 'type' => $this->getDerivativeId(), + 'reusable' => FALSE, + ]); + } + } + if ($this->blockContent instanceof AccessDependentInterface && $dependee = $this->getAccessDependency()) { + $this->blockContent->setAccessDependency($dependee); + } + return $this->blockContent; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form = parent::buildConfigurationForm($form, $form_state); + if ($this->isNew) { + // If the Content Block is new then don't provide a default label. + // @todo Blocks may be serialized before the layout is saved so we + // can't check $this->getEntity()->isNew(). + unset($form['label']['#default_value']); + } + $form['label']['#description'] = $this->t('The title of the block as shown to the user.'); + return $form; + } + + /** + * Saves the "block_content" entity for this plugin. + * + * @param bool $new_revision + * Whether to create new revision. + * @param bool $duplicate_block + * Whether to duplicate the "block_content" entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityStorageException + */ + public function saveBlockContent($new_revision = FALSE, $duplicate_block = FALSE) { + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = NULL; + if ($duplicate_block && !empty($this->configuration['block_revision_id']) && empty($this->configuration['block_serialized'])) { + $entity = $this->entityTypeManager->getStorage('block_content')->loadRevision($this->configuration['block_revision_id']); + $block = $entity->createDuplicate(); + } + elseif (isset($this->configuration['block_serialized'])) { + $block = unserialize($this->configuration['block_serialized']); + if (!empty($this->configuration['block_revision_id']) && $duplicate_block) { + $block = $block->createDuplicate(); + } + } + if ($block) { + if ($new_revision) { + $block->setNewRevision(); + } + $block->save(); + $this->configuration['block_revision_id'] = $block->getRevisionId(); + $this->configuration['block_serialized'] = NULL; + } + } + +} diff --git a/core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php b/core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php new file mode 100644 index 000000000000..07df40e8632f --- /dev/null +++ b/core/modules/layout_builder/src/Plugin/Derivative/InlineBlockContentDeriver.php @@ -0,0 +1,57 @@ +entityTypeManager = $entity_type_manager; + } + + /** + * {@inheritdoc} + */ + public static function create(ContainerInterface $container, $base_plugin_id) { + return new static( + $container->get('entity_type.manager') + ); + } + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + $this->derivatives = []; + if ($this->entityTypeManager->hasDefinition('block_content_type')) { + $block_content_types = $this->entityTypeManager->getStorage('block_content_type')->loadMultiple(); + foreach ($block_content_types as $id => $type) { + $this->derivatives[$id] = $base_plugin_definition; + $this->derivatives[$id]['admin_label'] = $type->label(); + $this->derivatives[$id]['config_dependencies'][$type->getConfigDependencyKey()][] = $type->getConfigDependencyName(); + } + } + return parent::getDerivativeDefinitions($base_plugin_definition); + } + +} diff --git a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php index 40c0503cea78..4fbf86f56fa8 100644 --- a/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php +++ b/core/modules/layout_builder/tests/src/Functional/LayoutBuilderTest.php @@ -101,6 +101,7 @@ public function testLayoutBuilderUi() { // Save the defaults. $assert_session->linkExists('Save Layout'); $this->clickLink('Save Layout'); + $assert_session->pageTextContains('The layout has been saved.'); $assert_session->addressEquals("$field_ui_prefix/display/default"); // The node uses the defaults, no overrides available. diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php new file mode 100644 index 000000000000..04ee6e64ed0d --- /dev/null +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php @@ -0,0 +1,539 @@ +drupalPlaceBlock('local_tasks_block'); + + $this->createContentType(['type' => 'bundle_with_section_field', 'new_revision' => TRUE]); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node title', + 'body' => [ + [ + 'value' => 'The node body', + ], + ], + ]); + $this->createNode([ + 'type' => 'bundle_with_section_field', + 'title' => 'The node2 title', + 'body' => [ + [ + 'value' => 'The node2 body', + ], + ], + ]); + $bundle = BlockContentType::create([ + 'id' => 'basic', + 'label' => 'Basic block', + 'revision' => 1, + ]); + $bundle->save(); + block_content_add_body_field($bundle->id()); + + $this->blockStorage = $this->container->get('entity_type.manager')->getStorage('block_content'); + } + + /** + * Tests adding and editing of inline blocks. + */ + public function testInlineBlocks() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + ])); + + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + // Add a basic block with the body field set. + $this->addInlineBlockToLayout('Block title', 'The DEFAULT block body'); + $this->assertSaveLayout(); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The DEFAULT block body'); + + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + $this->drupalGet('node/1/layout'); + + // Confirm the block can be edited. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('The DEFAULT block body', 'The NEW block body!'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body'); + $assert_session->pageTextNotContains('The DEFAULT block body'); + $this->drupalGet('node/2'); + // Node 2 should use default layout. + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + + // Add a basic block with the body field set. + $this->drupalGet('node/1/layout'); + $this->addInlineBlockToLayout('2nd Block title', 'The 2nd block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body!'); + $assert_session->pageTextContains('The 2nd block body'); + $this->drupalGet('node/2'); + // Node 2 should use default layout. + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + $assert_session->pageTextNotContains('The 2nd block body'); + + // Confirm the block can be edited. + $this->drupalGet('node/1/layout'); + /* @var \Behat\Mink\Element\NodeElement $inline_block_2 */ + $inline_block_2 = $page->findAll('css', static::INLINE_BLOCK_LOCATOR)[1]; + $uuid = $inline_block_2->getAttribute('data-layout-block-uuid'); + $block_css_locator = static::INLINE_BLOCK_LOCATOR . "[data-layout-block-uuid=\"$uuid\"]"; + $this->configureInlineBlock('The 2nd block body', 'The 2nd NEW block body!', $block_css_locator); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body!'); + $assert_session->pageTextContains('The 2nd NEW block body!'); + $this->drupalGet('node/2'); + // Node 2 should use default layout. + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body!'); + $assert_session->pageTextNotContains('The 2nd NEW block body!'); + + // The default layout entity block should be changed. + $this->drupalGet("$field_ui_prefix/display-layout/default"); + $assert_session->pageTextContains('The DEFAULT block body'); + // Confirm default layout still only has 1 entity block. + $assert_session->elementsCount('css', static::INLINE_BLOCK_LOCATOR, 1); + } + + /** + * Tests adding a new entity block and then not saving the layout. + * + * @dataProvider layoutNoSaveProvider + */ + public function testNoLayoutSave($operation, $no_save_link_text, $confirm_button_text) { + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + ])); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->assertEmpty($this->blockStorage->loadMultiple(), 'No entity blocks exist'); + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + + $this->drupalGet('node/1/layout'); + $this->addInlineBlockToLayout('Block title', 'The block body'); + $this->clickLink($no_save_link_text); + if ($confirm_button_text) { + $page->pressButton($confirm_button_text); + } + $this->drupalGet('node/1'); + $this->assertEmpty($this->blockStorage->loadMultiple(), 'No entity blocks were created when layout is canceled.'); + $assert_session->pageTextNotContains('The block body'); + + $this->drupalGet('node/1/layout'); + + $this->addInlineBlockToLayout('Block title', 'The block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The block body'); + $blocks = $this->blockStorage->loadMultiple(); + $this->assertEquals(count($blocks), 1); + /* @var \Drupal\Core\Entity\ContentEntityBase $block */ + $block = array_pop($blocks); + $revision_id = $block->getRevisionId(); + + // Confirm the block can be edited. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('The block body', 'The block updated body'); + $assert_session->pageTextContains('The block updated body'); + + $this->clickLink($no_save_link_text); + if ($confirm_button_text) { + $page->pressButton($confirm_button_text); + } + $this->drupalGet('node/1'); + + $blocks = $this->blockStorage->loadMultiple(); + // When reverting or canceling the update block should not be on the page. + $assert_session->pageTextNotContains('The block updated body'); + if ($operation === 'cancel') { + // When canceling the original block body should appear. + $assert_session->pageTextContains('The block body'); + + $this->assertEquals(count($blocks), 1); + $block = array_pop($blocks); + $this->assertEquals($block->getRevisionId(), $revision_id); + $this->assertEquals($block->get('body')->getValue()[0]['value'], 'The block body'); + } + else { + // The block should not be visible. + // Blocks are currently only deleted when the parent entity is deleted. + $assert_session->pageTextNotContains('The block body'); + } + } + + /** + * Provides test data for ::testNoLayoutSave(). + */ + public function layoutNoSaveProvider() { + return [ + 'cancel' => [ + 'cancel', + 'Cancel Layout', + NULL, + ], + 'revert' => [ + 'revert', + 'Revert to defaults', + 'Revert', + ], + ]; + } + + /** + * Saves a layout and asserts the message is correct. + * + * @throws \Behat\Mink\Exception\ExpectationException + * @throws \Behat\Mink\Exception\ResponseTextException + */ + protected function assertSaveLayout() { + $assert_session = $this->assertSession(); + $assert_session->linkExists('Save Layout'); + $this->clickLink('Save Layout'); + $this->assertNotEmpty($assert_session->waitForElement('css', '.messages--status')); + if (stristr($this->getUrl(), 'admin/structure') === FALSE) { + $assert_session->pageTextContains('The layout override has been saved.'); + } + else { + $assert_session->pageTextContains('The layout has been saved.'); + } + } + + /** + * Tests entity blocks revisioning. + */ + public function testInlineBlocksRevisioning() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + $this->drupalLogin($this->drupalCreateUser([ + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'administer nodes', + 'bypass node access', + ])); + + // Enable override. + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + $this->drupalGet('node/1/layout'); + + // Add a entity block. + $this->addInlineBlockToLayout('Block title', 'The DEFAULT block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + + $assert_session->pageTextContains('The DEFAULT block body'); + + /** @var \Drupal\node\NodeStorageInterface $node_storage */ + $node_storage = $this->container->get('entity_type.manager')->getStorage('node'); + $original_revision_id = $node_storage->getLatestRevisionId(1); + + // Create a new revision. + $this->drupalGet('node/1/edit'); + $page->findField('title[0][value]')->setValue('Node updated'); + $page->pressButton('Save'); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + + $assert_session->linkExists('Revisions'); + + // Update the block. + $this->drupalGet('node/1/layout'); + $this->configureInlineBlock('The DEFAULT block body', 'The NEW block body'); + $this->assertSaveLayout(); + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The NEW block body'); + $assert_session->pageTextNotContains('The DEFAULT block body'); + + $revision_url = "node/1/revisions/$original_revision_id"; + + // Ensure viewing the previous revision shows the previous block revision. + $this->drupalGet("$revision_url/view"); + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + + // Revert to first revision. + $revision_url = "$revision_url/revert"; + $this->drupalGet($revision_url); + $page->pressButton('Revert'); + + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + $assert_session->pageTextNotContains('The NEW block body'); + } + + /** + * Tests that entity blocks deleted correctly. + * + * @throws \Behat\Mink\Exception\ExpectationException + * @throws \Behat\Mink\Exception\ResponseTextException + */ + public function testDeletion() { + /** @var \Drupal\Core\Cron $cron */ + $cron = \Drupal::service('cron'); + $this->drupalLogin($this->drupalCreateUser([ + 'administer content types', + 'access contextual links', + 'configure any layout', + 'administer node display', + 'administer node fields', + 'administer nodes', + 'bypass node access', + ])); + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + + // Add a block to default layout. + $field_ui_prefix = 'admin/structure/types/manage/bundle_with_section_field'; + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + $this->addInlineBlockToLayout('Block title', 'The DEFAULT block body'); + $this->assertSaveLayout(); + + $this->assertCount(1, $this->blockStorage->loadMultiple()); + $default_block_id = $this->getLatestBlockEntityId(); + + // Ensure the block shows up on node pages. + $this->drupalGet('node/1'); + $assert_session->pageTextContains('The DEFAULT block body'); + $this->drupalGet('node/2'); + $assert_session->pageTextContains('The DEFAULT block body'); + + // Enable overrides. + $this->drupalPostForm("$field_ui_prefix/display/default", ['layout[allow_custom]' => TRUE], 'Save'); + + // Ensure we have 2 copies of the block in node overrides. + $this->drupalGet('node/1/layout'); + $this->assertSaveLayout(); + $node_1_block_id = $this->getLatestBlockEntityId(); + + $this->drupalGet('node/2/layout'); + $this->assertSaveLayout(); + $node_2_block_id = $this->getLatestBlockEntityId(); + $this->assertCount(3, $this->blockStorage->loadMultiple()); + + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + + $this->assertNotEmpty($this->blockStorage->load($default_block_id)); + // Remove block from default. + $this->removeInlineBlockFromLayout(); + $this->assertSaveLayout(); + // Ensure the block in the default was deleted. + $this->blockStorage->resetCache([$default_block_id]); + $this->assertEmpty($this->blockStorage->load($default_block_id)); + // Ensure other blocks still exist. + $this->assertCount(2, $this->blockStorage->loadMultiple()); + + $this->drupalGet('node/1/layout'); + $assert_session->pageTextContains('The DEFAULT block body'); + + // Remove block from override. + // Currently revisions are not actually created so this check will not pass. + // @see https://www.drupal.org/node/2937199 + /*$this->removeInlineBlockFromLayout(); + $this->assertSaveLayout(); + $cron->run(); + // Ensure entity block is not deleted because it is needed in revision. + $this->assertNotEmpty($this->blockStorage->load($node_1_block_id)); + $this->assertCount(2, $this->blockStorage->loadMultiple());*/ + + // Ensure entity block is deleted when node is deleted. + $this->drupalGet('node/1/delete'); + $page->pressButton('Delete'); + $this->assertEmpty(Node::load(1)); + $cron->run(); + $this->assertEmpty($this->blockStorage->load($node_1_block_id)); + $this->assertCount(1, $this->blockStorage->loadMultiple()); + + // Add another block to the default. + $this->drupalGet($field_ui_prefix . '/display/default'); + $this->clickLink('Manage layout'); + $assert_session->addressEquals("$field_ui_prefix/display-layout/default"); + $this->addInlineBlockToLayout('Title 2', 'Body 2'); + $this->assertSaveLayout(); + $cron->run(); + $default_block2_id = $this->getLatestBlockEntityId(); + $this->assertCount(2, $this->blockStorage->loadMultiple()); + + // Delete the other node so bundle can be deleted. + $this->drupalGet('node/2/delete'); + $page->pressButton('Delete'); + $this->assertEmpty(Node::load(2)); + $cron->run(); + // Ensure entity block was deleted. + $this->assertEmpty($this->blockStorage->load($node_2_block_id)); + $this->assertCount(1, $this->blockStorage->loadMultiple()); + + // Delete the bundle which has the default layout. + $this->drupalGet("$field_ui_prefix/delete"); + $page->pressButton('Delete'); + $cron->run(); + + // Ensure the entity block in default is deleted when bundle is deleted. + $this->assertEmpty($this->blockStorage->load($default_block2_id)); + $this->assertCount(0, $this->blockStorage->loadMultiple()); + } + + /** + * Gets the latest block entity id. + */ + protected function getLatestBlockEntityId() { + $block_ids = \Drupal::entityQuery('block_content')->sort('id', 'DESC')->range(0, 1)->execute(); + $block_id = array_pop($block_ids); + $this->assertNotEmpty($this->blockStorage->load($block_id)); + return $block_id; + } + + /** + * Removes an entity block from the layout but does not save the layout. + */ + protected function removeInlineBlockFromLayout() { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $rendered_block = $page->find('css', static::INLINE_BLOCK_LOCATOR)->getText(); + $this->assertNotEmpty($rendered_block); + $assert_session->pageTextContains($rendered_block); + $this->clickContextualLink(static::INLINE_BLOCK_LOCATOR, 'Remove block'); + $assert_session->assertWaitOnAjaxRequest(); + $page->find('css', '#drupal-off-canvas')->pressButton('Remove'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->pageTextNotContains($rendered_block); + } + + /** + * Adds an entity block to the layout. + * + * @param string $title + * The title field value. + * @param string $body + * The body field value. + * + * @throws \Behat\Mink\Exception\ElementNotFoundException + * @throws \Behat\Mink\Exception\ExpectationException + */ + protected function addInlineBlockToLayout($title, $body) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $page->clickLink('Add Block'); + $assert_session->assertWaitOnAjaxRequest(); + $this->assertNotEmpty($assert_session->waitForElementVisible('css', '.block-categories details:contains(Create new block)')); + $this->clickLink('Basic block'); + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->fieldValueEquals('Title', ''); + $page->findField('Title')->setValue($title); + $textarea = $assert_session->elementExists('css', '[name="settings[block_form][body][0][value]"]'); + $textarea->setValue($body); + $page->pressButton('Add Block'); + // @todo Replace with 'assertNoElementAfterWait()' after + // https://www.drupal.org/project/drupal/issues/2892440. + $assert_session->assertWaitOnAjaxRequest(); + $assert_session->elementNotExists('css', '#drupal-off-canvas'); + $found_new_text = FALSE; + /** @var \Behat\Mink\Element\NodeElement $element */ + foreach ($page->findAll('css', static::INLINE_BLOCK_LOCATOR) as $element) { + if (stristr($element->getText(), $body)) { + $found_new_text = TRUE; + break; + } + } + $this->assertNotEmpty($found_new_text, 'Found block text on page.'); + } + + /** + * Configures an inline block in the Layout Builder. + * + * @param string $old_body + * The old body field value. + * @param string $new_body + * The new body field value. + * @param string $block_css_locator + * The CSS locator to use to select the contextual link. + */ + protected function configureInlineBlock($old_body, $new_body, $block_css_locator = NULL) { + $block_css_locator = $block_css_locator ?: static::INLINE_BLOCK_LOCATOR; + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $this->clickContextualLink($block_css_locator, 'Configure'); + $textarea = $assert_session->waitForElementVisible('css', '[name="settings[block_form][body][0][value]"]'); + $this->assertNotEmpty($textarea); + $this->assertSame($old_body, $textarea->getValue()); + $textarea->setValue($new_body); + $page->pressButton('Update'); + $assert_session->assertWaitOnAjaxRequest(); + } + +} From 6b50ed6a47e8c19354a4f84409350c81fa7e1f06 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Wed, 20 Jun 2018 14:49:56 -0400 Subject: [PATCH 23/39] change non-reusable blocks. to blocks with parents --- .../Core/Access/AccessDependentInterface.php | 43 ----- .../Core/Access/AccessDependentTrait.php | 32 ---- .../block_content/block_content.install | 34 +++- .../block_content/block_content.module | 32 ++-- .../block_content.post_update.php | 18 +- .../optional/views.view.block_content.yml | 12 +- .../src/BlockContentAccessControlHandler.php | 13 +- .../src/BlockContentInterface.php | 58 ++++--- .../src/BlockContentListBuilder.php | 2 +- .../src/BlockContentViewsData.php | 12 ++ .../block_content/src/Entity/BlockContent.php | 64 ++++--- .../src/Plugin/Derivative/BlockContent.php | 3 +- .../src/Plugin/views/wizard/BlockContent.php | 13 +- .../TestSelection.php | 32 ++-- .../src/Functional/BlockContentListTest.php | 8 +- .../Functional/BlockContentListViewsTest.php | 8 +- .../Rest/BlockContentResourceTestBase.php | 7 +- .../BlockContentParentEntityUpdateTest.php | 159 ++++++++++++++++++ .../Update/BlockContentReusableUpdateTest.php | 131 --------------- .../Views/BlockContentWizardTest.php | 8 +- .../src/Kernel/BlockContentDeriverTest.php | 19 ++- ...ockContentEntityReferenceSelectionTest.php | 121 ++++++------- 22 files changed, 432 insertions(+), 397 deletions(-) delete mode 100644 core/lib/Drupal/Core/Access/AccessDependentInterface.php delete mode 100644 core/lib/Drupal/Core/Access/AccessDependentTrait.php create mode 100644 core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php delete mode 100644 core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php diff --git a/core/lib/Drupal/Core/Access/AccessDependentInterface.php b/core/lib/Drupal/Core/Access/AccessDependentInterface.php deleted file mode 100644 index a8386282e9b5..000000000000 --- a/core/lib/Drupal/Core/Access/AccessDependentInterface.php +++ /dev/null @@ -1,43 +0,0 @@ -getAccessDependency()->access($op, $account, TRUE); - * @endcode - */ -interface AccessDependentInterface { - - /** - * Sets the access dependency. - * - * @param \Drupal\Core\Access\AccessibleInterface $access_dependency - * The object upon which access depends. - * - * @return $this - */ - public function setAccessDependency(AccessibleInterface $access_dependency); - - /** - * Gets the access dependency. - * - * @return \Drupal\Core\Access\AccessibleInterface|null - * The access dependency or NULL if none has been set. - */ - public function getAccessDependency(); - -} diff --git a/core/lib/Drupal/Core/Access/AccessDependentTrait.php b/core/lib/Drupal/Core/Access/AccessDependentTrait.php deleted file mode 100644 index 94f2c93d958e..000000000000 --- a/core/lib/Drupal/Core/Access/AccessDependentTrait.php +++ /dev/null @@ -1,32 +0,0 @@ -accessDependency = $access_dependency; - return $this; - } - - /** - * {@inheritdoc} - */ - public function getAccessDependency() { - return $this->accessDependency; - } - -} diff --git a/core/modules/block_content/block_content.install b/core/modules/block_content/block_content.install index fab15b4f88c2..e8eb2fbeef2d 100644 --- a/core/modules/block_content/block_content.install +++ b/core/modules/block_content/block_content.install @@ -140,17 +140,35 @@ function block_content_update_8400() { } /** - * Add 'reusable' field to 'block_content' entities. + * Add parent entity fields to 'block_content' entities. */ function block_content_update_8600() { - $reusable = BaseFieldDefinition::create('boolean') - ->setLabel(t('Reusable')) - ->setDescription(t('A boolean indicating whether this block is reusable.')) + $update_manager = \Drupal::entityDefinitionUpdateManager(); + $parent_entity_type = BaseFieldDefinition::create('string') + ->setLabel(t('Parent entity type')) + ->setDescription(t('The parent entity type.')) ->setTranslatable(FALSE) ->setRevisionable(FALSE) - ->setDefaultValue(TRUE) - ->setInitialValue(TRUE); + ->setDefaultValue('') + ->setInitialValue(''); + + $update_manager->installFieldStorageDefinition('parent_entity_type', 'block_content', 'block_content', $parent_entity_type); + + $parent_entity_id = BaseFieldDefinition::create('string') + ->setLabel(t('Parent ID')) + ->setDescription(t('The parent entity ID.')) + ->setTranslatable(FALSE) + ->setRevisionable(FALSE) + ->setDefaultValue('') + ->setInitialValue(''); + + $update_manager->installFieldStorageDefinition('parent_entity_id', 'block_content', 'block_content', $parent_entity_id); +} + + +/** + * dfdf + */ +function block_content_update_8601() { - \Drupal::entityDefinitionUpdateManager() - ->installFieldStorageDefinition('reusable', 'block_content', 'block_content', $reusable); } diff --git a/core/modules/block_content/block_content.module b/core/modules/block_content/block_content.module index 71e1ee44d6ff..cf6521bb4522 100644 --- a/core/modules/block_content/block_content.module +++ b/core/modules/block_content/block_content.module @@ -115,43 +115,43 @@ function block_content_add_body_field($block_type_id, $label = 'Body') { * Alters any 'entity_reference' query where the entity type is * 'block_content' and the query has the tag 'block_content_access'. * - * These queries should only return reusable blocks unless a condition on - * reusable is explicitly set. + * These queries should only return with no parent blocks unless a condition on + * parent is explicitly set. * - * Since block_content entities can be set to be non-reusable they should by + * Since block_content entities can be set to be have a parent they should by * default not be selectable as entity reference values. A module can still * create a instance of * \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface - * that will will allow selection of non-reusable blocks by explicitly setting - * a condition on the reusable field. + * that will will allow selection of blocks with parents by explicitly setting + * a condition on either parent_entity_id or parent_entity_type fields. * * @see \Drupal\block_content\BlockContentAccessControlHandler */ function block_content_query_entity_reference_alter(AlterableInterface $query) { if ($query instanceof SelectInterface && $query->getMetaData('entity_type') === 'block_content' && $query->hasTag('block_content_access')) { $data_table = \Drupal::entityTypeManager()->getDefinition('block_content')->getDataTable(); - if (array_key_exists($data_table, $query->getTables()) && !_block_content_has_reusable_condition($query->conditions())) { - // If no reusable condition create a condition set to TRUE. - $query->condition("$data_table.reusable", TRUE); + if (array_key_exists($data_table, $query->getTables()) && !_block_content_has_parent_entity_condition($query->conditions())) { + // If no parent entity condition create a condition. + $query->isNull("$data_table.parent_entity_id"); } } } /** - * Utility function to find nested conditions using the reusable field. + * Utility function to find nested conditions using the parent entity fields. * * @param array $condition * The condition or condition group to check. * * @return bool - * Whether the conditions contain any condition using the reusable field. + * Whether conditions contain any condition using the parent entity fields. */ -function _block_content_has_reusable_condition(array $condition) { +function _block_content_has_parent_entity_condition(array $condition) { // If this is a condition group call this function recursively for each nested // condition until a condition is found that return TRUE. if (isset($condition['#conjunction'])) { foreach (array_filter($condition, 'is_array') as $nested_condition) { - if (_block_content_has_reusable_condition($nested_condition)) { + if (_block_content_has_parent_entity_condition($nested_condition)) { return TRUE; } } @@ -160,13 +160,15 @@ function _block_content_has_reusable_condition(array $condition) { if (isset($condition['field'])) { $field = $condition['field']; if (is_object($field) && $field instanceof ConditionInterface) { - return _block_content_has_reusable_condition($field->conditions()); + return _block_content_has_parent_entity_condition($field->conditions()); } $field_parts = explode('.', $field); - $data_table = $data_table = \Drupal::entityTypeManager()->getDefinition('block_content')->getDataTable(); + $data_table = \Drupal::entityTypeManager()->getDefinition('block_content')->getDataTable(); // With nested conditions the data table may have a suffix at the end like // 'block_content_field_data_2'. - return strpos($field_parts[0], $data_table) === 0 && $field_parts[1] === 'reusable'; + if (strpos($field_parts[0], $data_table) === 0) { + return $field_parts[1] === 'parent_entity_id' || $field_parts[1] === 'parent_entity_type'; + } } return FALSE; } diff --git a/core/modules/block_content/block_content.post_update.php b/core/modules/block_content/block_content.post_update.php index 927e588068cf..848f314d3825 100644 --- a/core/modules/block_content/block_content.post_update.php +++ b/core/modules/block_content/block_content.post_update.php @@ -6,9 +6,9 @@ */ /** - * Adds 'reusable filter to a Custom Block views. + * Adds 'has_parent' filter to Custom Block views. */ -function block_content_post_update_add_views_reusable_filter() { +function block_content_post_update_add_views_parent_filter() { $config_factory = \Drupal::configFactory(); $data_table = \Drupal::entityTypeManager() ->getDefinition('block_content') @@ -21,17 +21,17 @@ function block_content_post_update_add_views_reusable_filter() { } foreach ($view->get('display') as $display_name => $display) { // Update the default display and displays that have overridden filters. - if (!isset($display['display_options']['filters']['reusable']) && + if (!isset($display['display_options']['filters']['has_parent']) && ($display_name === 'default' || isset($display['display_options']['filters']))) { // Save off the base part of the config path we are updating. - $base = "display.$display_name.display_options.filters.reusable"; - $view->set("$base.id", 'reusable') - ->set("$base.plugin_id", 'boolean') + $base = "display.$display_name.display_options.filters.has_parent"; + $view->set("$base.id", 'has_parent') + ->set("$base.plugin_id", 'boolean_string') ->set("$base.table", $data_table) - ->set("$base.field", "reusable") - ->set("$base.value", "1") + ->set("$base.field", "has_parent") + ->set("$base.value", '0') ->set("$base.entity_type", "block_content") - ->set("$base.entity_field", "reusable"); + ->set("$base.entity_field", "parent_entity_id"); } } $view->save(); diff --git a/core/modules/block_content/config/optional/views.view.block_content.yml b/core/modules/block_content/config/optional/views.view.block_content.yml index 2c008864f32e..14f652ac6ef3 100644 --- a/core/modules/block_content/config/optional/views.view.block_content.yml +++ b/core/modules/block_content/config/optional/views.view.block_content.yml @@ -431,15 +431,15 @@ display: entity_type: block_content entity_field: type plugin_id: bundle - reusable: - id: reusable + has_parent: + id: has_parent table: block_content_field_data - field: reusable + field: has_parent relationship: none group_type: group admin_label: '' operator: '=' - value: '1' + value: '0' group: 1 exposed: false expose: @@ -467,8 +467,8 @@ display: default_group_multiple: { } group_items: { } entity_type: block_content - entity_field: reusable - plugin_id: boolean + entity_field: parent_entity_id + plugin_id: boolean_string sorts: { } title: 'Custom block library' header: { } diff --git a/core/modules/block_content/src/BlockContentAccessControlHandler.php b/core/modules/block_content/src/BlockContentAccessControlHandler.php index 2ad950f4d0c9..fc926a001509 100644 --- a/core/modules/block_content/src/BlockContentAccessControlHandler.php +++ b/core/modules/block_content/src/BlockContentAccessControlHandler.php @@ -2,7 +2,6 @@ namespace Drupal\block_content; -use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityAccessControlHandler; @@ -27,15 +26,9 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter $access = parent::checkAccess($entity, $operation, $account); } /** @var \Drupal\block_content\BlockContentInterface $entity */ - if ($entity->isReusable() === FALSE) { - if (!$entity instanceof AccessDependentInterface) { - throw new \LogicException("Non-reusable block entities must implement \Drupal\Core\Access\AccessDependentInterface for access control."); - } - $dependency = $entity->getAccessDependency(); - if (empty($dependency)) { - return AccessResult::forbidden("Non-reusable blocks must set an access dependency for access control."); - } - $access->andIf($dependency->access($operation, $account, TRUE))->addCacheableDependency($access); + if ($parent_entity = $entity->getParentEntity()) { + $parent_access = $parent_entity->access($operation, $account, TRUE); + $access = $access->andIf($parent_access)->addCacheableDependency($entity); } return $access; } diff --git a/core/modules/block_content/src/BlockContentInterface.php b/core/modules/block_content/src/BlockContentInterface.php index 50fabdfb1ad0..fac092f2fd6b 100644 --- a/core/modules/block_content/src/BlockContentInterface.php +++ b/core/modules/block_content/src/BlockContentInterface.php @@ -5,13 +5,14 @@ use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityPublishedInterface; use Drupal\Core\Entity\RevisionLogInterface; /** * Provides an interface defining a custom block entity. */ -interface BlockContentInterface extends ContentEntityInterface, EntityChangedInterface, RevisionLogInterface, EntityPublishedInterface, AccessDependentInterface { +interface BlockContentInterface extends ContentEntityInterface, EntityChangedInterface, RevisionLogInterface, EntityPublishedInterface { /** * Returns the block revision log message. @@ -49,24 +50,6 @@ public function setInfo($info); */ public function setRevisionLog($revision_log); - /** - * Determines if the block is reusable or not. - * - * @return bool - * Returns TRUE if reusable and FALSE otherwise. - */ - public function isReusable(); - - /** - * Sets the block to be reusable. - * - * @param bool $reusable - * Whether the block should be reusable, defaults to TRUE. - * - * @return $this - */ - public function setReusable($reusable = TRUE); - /** * Sets the theme value. * @@ -102,4 +85,41 @@ public function getTheme(); */ public function getInstances(); + /** + * Sets the parent entity. + * + * @param \Drupal\Core\Entity\EntityInterface $parent_entity + * The parent entity. + * + * @return \Drupal\block_content\BlockContentInterface + * The class instance that this method is called on. + */ + public function setParentEntity(EntityInterface $parent_entity); + + /** + * Gets the parent entity. + * + * @return \Drupal\Core\Entity\EntityInterface|null + * The parent entity or null if none exists. + * + * @todo How to deterine parent is set but no longer exists. + */ + public function getParentEntity(); + + /** + * Removes the parent entity. + * + * @return \Drupal\block_content\BlockContentInterface + * The class instance that this method is called on. + */ + public function removeParentEntity(); + + /** + * Whether the block has a parent entity set. + * + * @return bool + * TRUE if a parent entity is set, otherwise FALSE. + */ + public function hasParentEntity(); + } diff --git a/core/modules/block_content/src/BlockContentListBuilder.php b/core/modules/block_content/src/BlockContentListBuilder.php index 88545e09b4a9..3b37dfd51ccd 100644 --- a/core/modules/block_content/src/BlockContentListBuilder.php +++ b/core/modules/block_content/src/BlockContentListBuilder.php @@ -35,7 +35,7 @@ protected function getEntityIds() { $query = $this->getStorage()->getQuery() ->sort($this->entityType->getKey('id')); - $query->condition('reusable', TRUE); + $query->notExists('parent_entity_id'); // Only add the pager if a limit is specified. if ($this->limit) { diff --git a/core/modules/block_content/src/BlockContentViewsData.php b/core/modules/block_content/src/BlockContentViewsData.php index e9ff0eb4cd83..f7eecc598e40 100644 --- a/core/modules/block_content/src/BlockContentViewsData.php +++ b/core/modules/block_content/src/BlockContentViewsData.php @@ -21,6 +21,18 @@ public function getViewsData() { $data['block_content_field_data']['info']['field']['id'] = 'field'; $data['block_content_field_data']['info']['field']['link_to_entity default'] = TRUE; + $data['block_content_field_data']['has_parent'] = [ + 'title' => $this->t('Has Parent'), + 'help' => $this->t('Whether the block has a parent'), + 'field' => ['id' => 'field'], + 'filter' => [ + 'id' => 'boolean_string', + 'accept_null' => TRUE, + ], + 'entity field' => 'parent_entity_id', + 'real field' => 'parent_entity_id', + ]; + $data['block_content_field_data']['type']['field']['id'] = 'field'; $data['block_content_field_data']['table']['wizard_id'] = 'block_content'; diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index 2995e99cec10..e5c15f88cc78 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -2,8 +2,8 @@ namespace Drupal\block_content\Entity; -use Drupal\Core\Access\AccessDependentTrait; use Drupal\Core\Entity\EditorialContentEntityBase; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityStorageInterface; use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Field\BaseFieldDefinition; @@ -78,8 +78,6 @@ */ class BlockContent extends EditorialContentEntityBase implements BlockContentInterface { - use AccessDependentTrait; - /** * The theme the block is being created in. * @@ -121,7 +119,7 @@ public function getTheme() { */ public function postSave(EntityStorageInterface $storage, $update = TRUE) { parent::postSave($storage, $update); - if ($this->isReusable() || (isset($this->original) && $this->original->isReusable())) { + if (empty($this->get('parent_entity_id')->value) || (isset($this->original) && empty($this->original->get('parent_entity_id')->value))) { static::invalidateBlockPluginCache(); } } @@ -133,8 +131,8 @@ public static function postDelete(EntityStorageInterface $storage, array $entiti parent::postDelete($storage, $entities); /** @var \Drupal\block_content\BlockContentInterface $block */ foreach ($entities as $block) { - if ($block->isReusable()) { - // If any deleted blocks are reusable clear the block cache. + if (empty($block->get('parent_entity_id'))) { + // If any deleted blocks do not have a parent id clear the block cache. static::invalidateBlockPluginCache(); return; } @@ -212,13 +210,21 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setTranslatable(TRUE) ->setRevisionable(TRUE); - $fields['reusable'] = BaseFieldDefinition::create('boolean') - ->setLabel(t('Reusable')) - ->setDescription(t('A boolean indicating whether this block is reusable.')) + $fields['parent_entity_type'] = BaseFieldDefinition::create('string') + ->setLabel(t('Parent entity type')) + ->setDescription(t('The parent entity type.')) + ->setTranslatable(FALSE) + ->setRevisionable(FALSE) + ->setDefaultValue('') + ->setInitialValue(''); + + $fields['parent_entity_id'] = BaseFieldDefinition::create('string') + ->setLabel(t('Parent ID')) + ->setDescription(t('The parent entity ID.')) ->setTranslatable(FALSE) ->setRevisionable(FALSE) - ->setDefaultValue(TRUE) - ->setInitialValue(TRUE); + ->setDefaultValue('') + ->setInitialValue(''); return $fields; } @@ -302,26 +308,46 @@ public function setRevisionLogMessage($revision_log_message) { return $this; } + /** + * Invalidates the block plugin cache after changes and deletions. + */ + protected static function invalidateBlockPluginCache() { + // Invalidate the block cache to update custom block-based derivatives. + \Drupal::service('plugin.manager.block')->clearCachedDefinitions(); + } + /** * {@inheritdoc} */ - public function isReusable() { - return (bool) $this->get('reusable')->value; + public function setParentEntity(EntityInterface $parent_entity) { + $this->set('parent_entity_type', $parent_entity->getEntityTypeId()); + $this->set('parent_entity_id', $parent_entity->id()); + return $this; } /** * {@inheritdoc} */ - public function setReusable($reusable = TRUE) { - return $this->set('reusable', $reusable); + public function getParentEntity() { + if ($this->hasParentEntity()) { + return \Drupal::entityTypeManager()->getStorage($this->get('parent_entity_type')->value)->load($this->get('parent_entity_id')->value); + } + return NULL; } /** - * Invalidates the block plugin cache after changes and deletions. + * {@inheritdoc} */ - protected static function invalidateBlockPluginCache() { - // Invalidate the block cache to update custom block-based derivatives. - \Drupal::service('plugin.manager.block')->clearCachedDefinitions(); + public function removeParentEntity() { + $this->set('parent_entity_type', NULL); + $this->set('parent_entity_id', NULL); + return $this; } + /** + * {@inheritdoc} + */ + public function hasParentEntity() { + return !empty($this->get('parent_entity_type')->value) && !empty($this->get('parent_entity_id')->value); + } } diff --git a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php index ba1ab989688a..a650660f9a6a 100644 --- a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php +++ b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php @@ -43,7 +43,8 @@ public static function create(ContainerInterface $container, $base_plugin_id) { * {@inheritdoc} */ public function getDerivativeDefinitions($base_plugin_definition) { - $block_contents = $this->blockContentStorage->loadByProperties(['reusable' => TRUE]); + $block_ids = $this->blockContentStorage->getQuery()->notExists('parent_entity_id')->execute(); + $block_contents = $this->blockContentStorage->loadMultiple($block_ids); // Reset the discovered definitions. $this->derivatives = []; /** @var $block_content \Drupal\block_content\Entity\BlockContent */ diff --git a/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php b/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php index 607250153746..84c13d6be83c 100644 --- a/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php +++ b/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php @@ -20,14 +20,15 @@ class BlockContent extends WizardPluginBase { */ public function getFilters() { $filters = parent::getFilters(); - $filters['reusable'] = [ - 'id' => 'reusable', - 'plugin_id' => 'boolean', + $filters['has_parent'] = [ + 'id' => 'has_parent', + 'plugin_id' => 'boolean_string', 'table' => $this->base_table, - 'field' => 'reusable', - 'value' => '1', + 'field' => 'has_parent', + 'operator' => '=', + 'value' => '0', 'entity_type' => $this->entityTypeId, - 'entity_field' => 'reusable', + 'entity_field' => 'parent_entity_id', ]; return $filters; } diff --git a/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php b/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php index 82b8aa90dc0d..1c3f753478c1 100644 --- a/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php +++ b/core/modules/block_content/tests/modules/block_content_test/src/Plugin/EntityReferenceSelection/TestSelection.php @@ -5,7 +5,7 @@ use Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection; /** - * Test EntityReferenceSelection class that adds various 'reusable' conditions. + * Test EntityReferenceSelection that adds various parent entity conditions. */ class TestSelection extends DefaultSelection { @@ -31,33 +31,35 @@ public function setTestMode($testMode) { */ protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') { $query = parent::buildEntityQuery($match, $match_operator); + if (strpos($this->testMode, 'parent_entity_id') === 0) { + $field = 'parent_entity_id'; + } + else { + $field = 'parent_entity_type'; + } switch ($this->testMode) { - case 'reusable_condition_false': - $query->condition("reusable", 0); - break; - - case 'reusable_condition_exists': - $query->exists('reusable'); + case "{$field}_condition_false": + $query->notExists($field); break; - case 'reusable_condition_group_false': + case "{$field}_condition_group_false": $group = $query->andConditionGroup() - ->condition("reusable", 0) + ->notExists($field) ->exists('type'); $query->condition($group); break; - case 'reusable_condition_group_true': + case "{$field}_condition_group_true": $group = $query->andConditionGroup() - ->condition("reusable", 1) + ->exists($field) ->exists('type'); $query->condition($group); break; - case 'reusable_condition_nested_group_false': + case "{$field}_condition_nested_group_false": $query->exists('type'); $sub_group = $query->andConditionGroup() - ->condition("reusable", 0) + ->notExists($field) ->exists('type'); $group = $query->andConditionGroup() ->exists('type') @@ -65,10 +67,10 @@ protected function buildEntityQuery($match = NULL, $match_operator = 'CONTAINS') $query->condition($group); break; - case 'reusable_condition_nested_group_true': + case "{$field}_condition_nested_group_true": $query->exists('type'); $sub_group = $query->andConditionGroup() - ->condition("reusable", 1) + ->exists($field) ->exists('type'); $group = $query->andConditionGroup() ->exists('type') diff --git a/core/modules/block_content/tests/src/Functional/BlockContentListTest.php b/core/modules/block_content/tests/src/Functional/BlockContentListTest.php index 8919c05d0019..12a8dcc12987 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentListTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentListTest.php @@ -108,17 +108,17 @@ public function testListing() { $this->assertText(t('There are no custom blocks yet.')); $block_content = BlockContent::create([ - 'info' => 'Non-reusable block', + 'info' => 'Block with parent', 'type' => 'basic', - 'reusable' => FALSE, ]); + $block_content->setParentEntity($this->loggedInUser); $block_content->save(); $this->drupalGet('admin/structure/block/block-content'); // Confirm that the empty text is displayed. $this->assertSession()->pageTextContains('There are no custom blocks yet.'); - // Confirm the non-reusable block is not on the page. - $this->assertSession()->pageTextNotContains('Non-reusable block'); + // Confirm the block with a parent is not on the page. + $this->assertSession()->pageTextNotContains('Block with parent'); } } diff --git a/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php b/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php index f9cf29eb77bc..8a229e742c2a 100644 --- a/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php +++ b/core/modules/block_content/tests/src/Functional/BlockContentListViewsTest.php @@ -116,17 +116,17 @@ public function testListing() { $this->assertLink('custom block'); $block_content = BlockContent::create([ - 'info' => 'Non-reusable block', + 'info' => 'Block with parent', 'type' => 'basic', - 'reusable' => FALSE, ]); + $block_content->setParentEntity($this->loggedInUser); $block_content->save(); $this->drupalGet('admin/structure/block/block-content'); // Confirm that the empty text is displayed. $this->assertSession()->pageTextContains('There are no custom blocks available.'); - // Confirm the non-reusable block is not on the page. - $this->assertSession()->pageTextNotContains('Non-reusable block'); + // Confirm the Block with parent is not on the page. + $this->assertSession()->pageTextNotContains('Block with parent'); } } diff --git a/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php b/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php index 4a3ac11f4c5a..13b2df05b1c9 100644 --- a/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php +++ b/core/modules/block_content/tests/src/Functional/Rest/BlockContentResourceTestBase.php @@ -92,11 +92,8 @@ protected function getExpectedNormalizedEntity() { 'value' => 'en', ], ], - 'reusable' => [ - [ - 'value' => TRUE, - ], - ], + 'parent_entity_type' => [], + 'parent_entity_id' => [], 'type' => [ [ 'target_id' => 'basic', diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php new file mode 100644 index 000000000000..3ffeab7c01dc --- /dev/null +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php @@ -0,0 +1,159 @@ +databaseDumpFiles = [ + __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.4.0.bare.standard.php.gz', + ]; + } + + /** + * Tests adding parent entity fields to the block content entity type. + * + * @see block_content_update_8600 + * @see block_content_post_update_add_views_parent_filter + */ + public function testParentFieldsAddition() { + $assert_session = $this->assertSession(); + $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); + + // Delete custom block library view. + View::load('block_content')->delete(); + // Install the test module with the 'block_content' view with an extra + // display with overridden filters. This extra display should also have the + // 'has_parent' filter added so that it does not expose fields with parents + // This display also a filter only show blocks that contain 'block2' in the + // 'info' field. + $this->container->get('module_installer')->install(['block_content_view_override']); + + // Run updates. + $this->runUpdates(); + + // Check that the field exists and is configured correctly. + $parent_type_field = $entity_definition_update_manager->getFieldStorageDefinition('parent_entity_type', 'block_content'); + $this->assertEquals('Parent entity type', $parent_type_field->getLabel()); + $this->assertEquals('The parent entity type.', $parent_type_field->getDescription()); + $this->assertEquals(FALSE, $parent_type_field->isRevisionable()); + $this->assertEquals(FALSE, $parent_type_field->isTranslatable()); + + $after_block1 = BlockContent::create([ + 'info' => 'After update block1', + 'type' => 'basic_block', + ]); + $after_block1->save(); + // Add second block that will be shown with the 'info' filter on the + // additional view display. + $after_block2 = BlockContent::create([ + 'info' => 'After update block2', + 'type' => 'basic_block', + ]); + $after_block2->save(); + + $this->assertEquals(FALSE, $after_block1->hasParentEntity()); + $this->assertEquals(FALSE, $after_block2->hasParentEntity()); + + $admin_user = $this->drupalCreateUser(['administer blocks']); + $this->drupalLogin($admin_user); + + $block_with_parent = BlockContent::create([ + 'info' => 'block1 with parent', + 'type' => 'basic_block', + ]); + $block_with_parent->setParentEntity($admin_user); + $block_with_parent->save(); + // Add second block that would be shown with the 'info' filter on the + // additional view display if the 'has_parent' filter was not added. + $block2_with_parent = BlockContent::create([ + 'info' => 'block2 with parent', + 'type' => 'basic_block', + ]); + $block2_with_parent->setParentEntity($admin_user); + $block2_with_parent->save(); + $this->assertEquals(TRUE, $block_with_parent->hasParentEntity()); + $this->assertEquals(TRUE, $block2_with_parent->hasParentEntity()); + + + + // Ensure the Custom Block view shows the blocks without parents only. + $this->drupalGet('admin/structure/block/block-content'); + file_put_contents('/Users/ted.bowman/Sites/www/test.html', $this->getSession()->getPage()->getOuterHtml()); + $assert_session->statusCodeEquals('200'); + $assert_session->responseContains('view-id-block_content'); + $assert_session->pageTextContains($after_block1->label()); + $assert_session->pageTextContains($after_block2->label()); + $assert_session->pageTextNotContains($block_with_parent->label()); + $assert_session->pageTextNotContains($block2_with_parent->label()); + + // Ensure the view's other display also only shows blocks without parent and + // still filters on the 'info' field. + $this->drupalGet('extra-view-display'); + $assert_session->statusCodeEquals('200'); + $assert_session->responseContains('view-id-block_content'); + $assert_session->pageTextNotContains($after_block1->label()); + $assert_session->pageTextContains($after_block2->label()); + $assert_session->pageTextNotContains($block_with_parent->label()); + $assert_session->pageTextNotContains($block2_with_parent->label()); + + // Ensure the Custom Block listing without Views installed shows the only + // blocks without parents. + $this->drupalGet('admin/structure/block/block-content'); + $this->container->get('module_installer')->uninstall(['views_ui', 'views']); + $this->drupalGet('admin/structure/block/block-content'); + $assert_session->statusCodeEquals('200'); + $assert_session->responseNotContains('view-id-block_content'); + $assert_session->pageTextContains($after_block1->label()); + $assert_session->pageTextContains($after_block2->label()); + $assert_session->pageTextNotContains($block_with_parent->label()); + $assert_session->pageTextNotContains($block2_with_parent->label()); + + $this->drupalGet('block/' . $after_block1->id()); + $assert_session->statusCodeEquals('200'); + + // Ensure the user who can access a block parent they can access edit form + // edit route is not accessible. + $this->drupalGet('block/' . $block_with_parent->id()); + $assert_session->statusCodeEquals('200'); + + $this->drupalLogout(); + + $this->drupalLogin($this->createUser([ + 'access user profiles', + 'administer blocks', + ])); + $this->drupalGet('block/' . $after_block1->id()); + $assert_session->statusCodeEquals('200'); + + $this->drupalGet('block/' . $block_with_parent->id()); + $assert_session->statusCodeEquals('403'); + + $this->drupalLogin($this->createUser([ + 'administer blocks', + ])); + + $this->drupalGet('block/' . $after_block1->id()); + $assert_session->statusCodeEquals('200'); + + $this->drupalGet('user/' . $admin_user->id()); + $assert_session->statusCodeEquals('403'); + + $this->drupalGet('block/' . $block_with_parent->id()); + $assert_session->statusCodeEquals('403'); + + } + +} diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php deleted file mode 100644 index 7803f40813d4..000000000000 --- a/core/modules/block_content/tests/src/Functional/Update/BlockContentReusableUpdateTest.php +++ /dev/null @@ -1,131 +0,0 @@ -databaseDumpFiles = [ - __DIR__ . '/../../../../../system/tests/fixtures/update/drupal-8.4.0.bare.standard.php.gz', - ]; - } - - /** - * Tests adding a reusable field to the block content entity type. - * - * @see block_content_update_8600 - * @see block_content_post_update_add_views_reusable_filter - */ - public function testReusableFieldAddition() { - $assert_session = $this->assertSession(); - $entity_definition_update_manager = \Drupal::entityDefinitionUpdateManager(); - - // Delete custom block library view. - View::load('block_content')->delete(); - // Install the test module with the 'block_content' view with an extra - // display with overridden filters. This extra display should also have a - // filter added for 'reusable' field so that it does not expose non-reusable - // fields. This display also a filter only show blocks that contain - // 'block2' in the 'info' field. - $this->container->get('module_installer')->install(['block_content_view_override']); - - // Run updates. - $this->runUpdates(); - - // Check that the field exists and is configured correctly. - $reusable_field = $entity_definition_update_manager->getFieldStorageDefinition('reusable', 'block_content'); - $this->assertEquals('Reusable', $reusable_field->getLabel()); - $this->assertEquals('A boolean indicating whether this block is reusable.', $reusable_field->getDescription()); - $this->assertEquals(FALSE, $reusable_field->isRevisionable()); - $this->assertEquals(FALSE, $reusable_field->isTranslatable()); - - $after_block1 = BlockContent::create([ - 'info' => 'After update block1', - 'type' => 'basic_block', - ]); - $after_block1->save(); - // Add second block that will be shown with the 'info' filter on the - // additional view display. - $after_block2 = BlockContent::create([ - 'info' => 'After update block2', - 'type' => 'basic_block', - ]); - $after_block2->save(); - - $this->assertEquals(TRUE, $after_block1->isReusable()); - $this->assertEquals(TRUE, $after_block2->isReusable()); - - $non_reusable_block = BlockContent::create([ - 'info' => 'non-reusable block1', - 'type' => 'basic_block', - 'reusable' => FALSE, - ]); - $non_reusable_block->save(); - // Add second block that will be would shown with the 'info' filter on the - // additional view display if the 'reusable filter was not added. - $non_reusable_block2 = BlockContent::create([ - 'info' => 'non-reusable block2', - 'type' => 'basic_block', - 'reusable' => FALSE, - ]); - $non_reusable_block2->save(); - $this->assertEquals(FALSE, $non_reusable_block->isReusable()); - $this->assertEquals(FALSE, $non_reusable_block2->isReusable()); - - $admin_user = $this->drupalCreateUser(['administer blocks']); - $this->drupalLogin($admin_user); - - // Ensure the Custom Block view shows the reusable blocks but not - // the non-reusable block. - $this->drupalGet('admin/structure/block/block-content'); - $assert_session->statusCodeEquals('200'); - $assert_session->responseContains('view-id-block_content'); - $assert_session->pageTextContains($after_block1->label()); - $assert_session->pageTextContains($after_block2->label()); - $assert_session->pageTextNotContains($non_reusable_block->label()); - $assert_session->pageTextNotContains($non_reusable_block2->label()); - - // Ensure the views other display also filters out non-reusable blocks and - // still filters on the 'info' field. - $this->drupalGet('extra-view-display'); - $assert_session->statusCodeEquals('200'); - $assert_session->responseContains('view-id-block_content'); - $assert_session->pageTextNotContains($after_block1->label()); - $assert_session->pageTextContains($after_block2->label()); - $assert_session->pageTextNotContains($non_reusable_block->label()); - $assert_session->pageTextNotContains($non_reusable_block2->label()); - - $this->drupalGet('block/' . $after_block1->id()); - $assert_session->statusCodeEquals('200'); - - // Ensure that non-reusable blocks edit form edit route is not accessible. - $this->drupalGet('block/' . $non_reusable_block->id()); - $assert_session->statusCodeEquals('403'); - - // Ensure the Custom Block listing without Views installed shows the - // reusable blocks but not the non-reusable blocks. - // the non-reusable block. - $this->drupalGet('admin/structure/block/block-content'); - $this->container->get('module_installer')->uninstall(['views_ui', 'views']); - $this->drupalGet('admin/structure/block/block-content'); - $assert_session->statusCodeEquals('200'); - $assert_session->responseNotContains('view-id-block_content'); - $assert_session->pageTextContains($after_block1->label()); - $assert_session->pageTextContains($after_block2->label()); - $assert_session->pageTextNotContains($non_reusable_block->label()); - $assert_session->pageTextNotContains($non_reusable_block2->label()); - } - -} diff --git a/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php b/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php index 2c59e5c50fe8..d6d5425b1959 100644 --- a/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php +++ b/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php @@ -43,10 +43,10 @@ public function testViewAddBlockContent() { $display_options = $view->getDisplay('default')['display_options']; - $this->assertEquals('block_content', $display_options['filters']['reusable']['entity_type']); - $this->assertEquals('reusable', $display_options['filters']['reusable']['entity_field']); - $this->assertEquals('boolean', $display_options['filters']['reusable']['plugin_id']); - $this->assertEquals('1', $display_options['filters']['reusable']['value']); + $this->assertEquals('block_content', $display_options['filters']['has_parent']['entity_type']); + $this->assertEquals('parent_entity_id', $display_options['filters']['has_parent']['entity_field']); + $this->assertEquals('boolean_string', $display_options['filters']['has_parent']['plugin_id']); + $this->assertEquals('0', $display_options['filters']['has_parent']['value']); } } diff --git a/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php b/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php index 600fc75fab97..dededb611a96 100644 --- a/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php +++ b/core/modules/block_content/tests/src/Kernel/BlockContentDeriverTest.php @@ -6,6 +6,7 @@ use Drupal\block_content\Entity\BlockContentType; use Drupal\Component\Plugin\PluginBase; use Drupal\KernelTests\KernelTestBase; +use Drupal\user\Entity\User; /** * Tests block content plugin deriver. @@ -25,14 +26,15 @@ class BlockContentDeriverTest extends KernelTestBase { public function setUp() { parent::setUp(); $this->installSchema('system', ['sequence']); + $this->installSchema('system', ['sequences']); $this->installEntitySchema('user'); $this->installEntitySchema('block_content'); } /** - * Tests that only reusable blocks are derived. + * Tests that block with parents are not derived. */ - public function testReusableBlocksOnlyAreDerived() { + public function testBlocksWithParentsNotDerived() { // Create a block content type. $block_content_type = BlockContentType::create([ 'id' => 'spiffy', @@ -47,18 +49,23 @@ public function testReusableBlocksOnlyAreDerived() { ]); $block_content->save(); - // Ensure the reusable block content is provided as a derivative block + // Ensure block entity with no parent is provided as a derivative block // plugin. /** @var \Drupal\Core\Block\BlockManagerInterface $block_manager */ $block_manager = $this->container->get('plugin.manager.block'); $plugin_id = 'block_content' . PluginBase::DERIVATIVE_SEPARATOR . $block_content->uuid(); $this->assertTrue($block_manager->hasDefinition($plugin_id)); - // Set the block not to be reusable. - $block_content->setReusable(FALSE); + // Set the block not to have a parent. + $user = User::create([ + 'name' => 'username', + 'status' => 1, + ]); + $user->save(); + $block_content->setParentEntity($user); $block_content->save(); - // Ensure the non-reusable block content is not provided a derivative block + // Ensure the block content with a parent is not provided a derivative block // plugin. $this->assertFalse($block_manager->hasDefinition($plugin_id)); } diff --git a/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php b/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php index 882e653eaf40..d401c56ebc7d 100644 --- a/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php +++ b/core/modules/block_content/tests/src/Kernel/BlockContentEntityReferenceSelectionTest.php @@ -6,9 +6,10 @@ use Drupal\block_content\Entity\BlockContentType; use Drupal\block_content_test\Plugin\EntityReferenceSelection\TestSelection; use Drupal\KernelTests\KernelTestBase; +use Drupal\user\Entity\User; /** - * Tests EntityReference selection handlers don't return non-reusable blocks. + * Tests EntityReference selection handlers don't return blocks with parents. * * @see block_content_query_block_content_access_alter() * @@ -40,6 +41,7 @@ class BlockContentEntityReferenceSelectionTest extends KernelTestBase { public function setUp() { parent::setUp(); $this->installSchema('system', ['sequence']); + $this->installSchema('system', ['sequences']); $this->installEntitySchema('user'); $this->installEntitySchema('block_content'); @@ -54,26 +56,31 @@ public function setUp() { } /** - * Tests that non-reusable blocks are not referenceable entities. + * Tests that blocks with parent are not referenceable entities. * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException * @throws \Exception */ public function testReferenceableEntities() { - // And reusable and non-reusable block content entities. - $block_content_reusable = BlockContent::create([ - 'info' => 'Reusable Block', + $user = User::create([ + 'name' => 'username', + 'status' => 1, + ]); + $user->save(); + + // And block content entities with and without parents. + $block_content = BlockContent::create([ + 'info' => 'Block no parent', 'type' => 'spiffy', - 'reusable' => TRUE, ]); - $block_content_reusable->save(); - $block_content_nonreusable = BlockContent::create([ - 'info' => 'Non-reusable Block', + $block_content->save(); + $block_content_with_parent = BlockContent::create([ + 'info' => 'Block with parent', 'type' => 'spiffy', - 'reusable' => FALSE, ]); - $block_content_nonreusable->save(); + $block_content_with_parent->setParentEntity($user); + $block_content_with_parent->save(); // Ensure that queries without all the tags are not altered. $query = $this->entityTypeManager->getStorage('block_content')->getQuery(); @@ -89,7 +96,7 @@ public function testReferenceableEntities() { // Use \Drupal\Core\Entity\Plugin\EntityReferenceSelection\DefaultSelection // class to test that getReferenceableEntities() does not get the - // non-reusable entity. + // entity wth a parent. $configuration = [ 'target_type' => 'block_content', 'target_bundles' => ['spiffy' => 'spiffy'], @@ -99,61 +106,57 @@ public function testReferenceableEntities() { // Setup the 3 expectation cases. $both_blocks = [ 'spiffy' => [ - $block_content_reusable->id() => $block_content_reusable->label(), - $block_content_nonreusable->id() => $block_content_nonreusable->label(), + $block_content->id() => $block_content->label(), + $block_content_with_parent->id() => $block_content_with_parent->label(), ], ]; - $reusable_block = ['spiffy' => [$block_content_reusable->id() => $block_content_reusable->label()]]; - $non_reusable_block = ['spiffy' => [$block_content_nonreusable->id() => $block_content_nonreusable->label()]]; + $block_no_parent = ['spiffy' => [$block_content->id() => $block_content->label()]]; + $block_with_parent = ['spiffy' => [$block_content_with_parent->id() => $block_content_with_parent->label()]]; $this->assertEquals( - $reusable_block, + $block_no_parent, $selection_handler->getReferenceableEntities() ); // Test various ways in which an EntityReferenceSelection plugin could set - // the 'reusable' condition. If the plugin has set a condition on 'reusable' - // at all then 'block_content_query_entity_reference_alter()' will not set - // a reusable condition. - $selection_handler->setTestMode('reusable_condition_false'); - $this->assertEquals( - $non_reusable_block, - $selection_handler->getReferenceableEntities() - ); - - $selection_handler->setTestMode('reusable_condition_exists'); - $this->assertEquals( - $both_blocks, - $selection_handler->getReferenceableEntities() - ); - - $selection_handler->setTestMode('reusable_condition_group_false'); - $this->assertEquals( - $non_reusable_block, - $selection_handler->getReferenceableEntities() - ); - - $selection_handler->setTestMode('reusable_condition_group_true'); - $this->assertEquals( - $reusable_block, - $selection_handler->getReferenceableEntities() - ); - - $selection_handler->setTestMode('reusable_condition_nested_group_false'); - $this->assertEquals( - $non_reusable_block, - $selection_handler->getReferenceableEntities() - ); - - $selection_handler->setTestMode('reusable_condition_nested_group_true'); - $this->assertEquals( - $reusable_block, - $selection_handler->getReferenceableEntities() - ); - - // Change the block to reusable. - $block_content_nonreusable->setReusable(TRUE); - $block_content_nonreusable->save(); + // a condition on either the 'parent_entity_id' or 'parent_entity_type' + // fields. If the plugin has set a condition on either of these fields + // then 'block_content_query_entity_reference_alter()' will not set + // a parent condition. + foreach (['parent_entity_id', 'parent_entity_type'] as $field) { + $selection_handler->setTestMode("{$field}_condition_false"); + $this->assertEquals( + $block_no_parent, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode("{$field}_condition_group_false"); + $this->assertEquals( + $block_no_parent, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode("{$field}_condition_group_true"); + $this->assertEquals( + $block_with_parent, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode("{$field}_condition_nested_group_false"); + $this->assertEquals( + $block_no_parent, + $selection_handler->getReferenceableEntities() + ); + + $selection_handler->setTestMode("{$field}_condition_nested_group_true"); + $this->assertEquals( + $block_with_parent, + $selection_handler->getReferenceableEntities() + ); + } + + $block_content_with_parent->removeParentEntity(); + $block_content_with_parent->save(); // Don't use any conditions. $selection_handler->setTestMode(NULL); // Ensure that the block is now returned as a referenceable entity. From 2a6f8b6c46955a5c7edf286b55f4d6a2ecab0cb9 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Wed, 20 Jun 2018 17:20:30 -0400 Subject: [PATCH 24/39] layout builder tests pass with parent change --- .../layout_builder/layout_builder.install | 74 ------------- .../layout_builder.services.yml | 3 - .../layout_builder/src/EntityOperations.php | 104 ++++++++++++++---- .../BlockComponentRenderArray.php | 13 --- .../src/InlineBlockContentUsage.php | 96 ---------------- .../Plugin/Block/InlineBlockContentBlock.php | 13 +-- 6 files changed, 87 insertions(+), 216 deletions(-) delete mode 100644 core/modules/layout_builder/src/InlineBlockContentUsage.php diff --git a/core/modules/layout_builder/layout_builder.install b/core/modules/layout_builder/layout_builder.install index 42cf53f67539..acb1e4fdf3d9 100644 --- a/core/modules/layout_builder/layout_builder.install +++ b/core/modules/layout_builder/layout_builder.install @@ -6,8 +6,6 @@ */ use Drupal\Core\Cache\Cache; -use Drupal\Core\Database\Database; -use Drupal\Core\Entity\EntityTypeInterface; use Drupal\layout_builder\Entity\LayoutBuilderEntityViewDisplay; use Drupal\layout_builder\Section; @@ -40,75 +38,3 @@ function layout_builder_install() { // prepare for future changes. Cache::invalidateTags(['rendered']); } - -/** - * Implements hook_schema(). - */ -function layout_builder_schema() { - $schema['inline_block_content_usage'] = [ - 'description' => 'Track where a block_content entity is used.', - 'fields' => [ - 'block_content_id' => [ - 'description' => 'The block_content entity ID.', - 'type' => 'int', - 'unsigned' => TRUE, - 'not null' => TRUE, - ], - 'layout_entity_type' => [ - 'description' => 'The entity type of the parent entity.', - 'type' => 'varchar_ascii', - 'length' => EntityTypeInterface::ID_MAX_LENGTH, - 'not null' => FALSE, - 'default' => '', - ], - 'layout_entity_id' => [ - 'description' => 'The ID of the parent entity.', - 'type' => 'varchar_ascii', - 'length' => 128, - 'not null' => FALSE, - 'default' => 0, - ], - ], - 'primary key' => ['block_content_id'], - 'indexes' => [ - 'type_id' => ['layout_entity_type', 'layout_entity_id'], - ], - ]; - return $schema; -} - -/** - * Create the 'inline_block_content_usage' table. - */ -function layout_builder_update_8001() { - $inline_block_content_usage = [ - 'description' => 'Track where a entity is used.', - 'fields' => [ - 'block_content_id' => [ - 'description' => 'The block_content entity ID.', - 'type' => 'int', - 'unsigned' => TRUE, - 'not null' => TRUE, - ], - 'layout_entity_type' => [ - 'description' => 'The entity type of the parent entity.', - 'type' => 'varchar_ascii', - 'length' => EntityTypeInterface::ID_MAX_LENGTH, - 'not null' => FALSE, - 'default' => '', - ], - 'layout_entity_id' => [ - 'description' => 'The ID of the parent entity.', - 'type' => 'varchar_ascii', - 'length' => 128, - 'not null' => FALSE, - 'default' => 0, - ], - ], - 'primary key' => ['block_content_id'], - 'indexes' => [ - 'type_id' => ['layout_entity_type', 'layout_entity_id'], - ], - ]; - Database::getConnection()->schema()->createTable('inline_block_content_usage', $inline_block_content_usage); -} diff --git a/core/modules/layout_builder/layout_builder.services.yml b/core/modules/layout_builder/layout_builder.services.yml index 1f3a70e44e8b..4fe50929bd48 100644 --- a/core/modules/layout_builder/layout_builder.services.yml +++ b/core/modules/layout_builder/layout_builder.services.yml @@ -39,6 +39,3 @@ services: logger.channel.layout_builder: parent: logger.channel_base arguments: ['layout_builder'] - inline_block_content.usage: - class: Drupal\layout_builder\InlineBlockContentUsage - arguments: ['@database'] diff --git a/core/modules/layout_builder/src/EntityOperations.php b/core/modules/layout_builder/src/EntityOperations.php index b6466dfbdb8a..4a15c4b67188 100644 --- a/core/modules/layout_builder/src/EntityOperations.php +++ b/core/modules/layout_builder/src/EntityOperations.php @@ -3,6 +3,7 @@ namespace Drupal\layout_builder; use Drupal\Component\Plugin\PluginInspectionInterface; +use Drupal\Core\Database\Database; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; @@ -20,35 +21,33 @@ class EntityOperations implements ContainerInjectionInterface { /** - * Inline block content usage tracking service. + * The block storage. * - * @var \Drupal\layout_builder\InlineBlockContentUsage + * @var \Drupal\Core\Entity\EntityStorageInterface */ - protected $usage; + protected $storage; /** - * The block storage. + * The entity type manager. * - * @var \Drupal\Core\Entity\EntityStorageInterface + * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ - protected $storage; + protected $entityTypeManager; /** * Constructs a new EntityOperations object. * * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entityTypeManager * The entity type manager service. - * @param \Drupal\layout_builder\InlineBlockContentUsage $usage - * Inline block content usage tracking service. * * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ - public function __construct(EntityTypeManagerInterface $entityTypeManager, InlineBlockContentUsage $usage) { + public function __construct(EntityTypeManagerInterface $entityTypeManager) { + $this->entityTypeManager = $entityTypeManager; if ($entityTypeManager->hasDefinition('block_content')) { $this->storage = $entityTypeManager->getStorage('block_content'); } - $this->usage = $usage; } /** @@ -56,8 +55,7 @@ public function __construct(EntityTypeManagerInterface $entityTypeManager, Inlin */ public static function create(ContainerInterface $container) { return new static( - $container->get('entity_type.manager'), - $container->get('inline_block_content.usage') + $container->get('entity_type.manager') ); } @@ -94,7 +92,7 @@ protected function removeUnusedForEntityOnSave(EntityInterface $entity) { // some blocks that need to be removed. if ($original_revision_ids = array_diff($this->getInBlockRevisionIdsInSection($original_sections), $current_revision_ids)) { if ($removed_ids = array_diff($this->getBlockIdsForRevisionIds($original_revision_ids), $this->getBlockIdsForRevisionIds($current_revision_ids))) { - $this->deleteBlocksAndUsage($removed_ids); + $this->deleteBlocks($removed_ids); } } } @@ -107,7 +105,22 @@ protected function removeUnusedForEntityOnSave(EntityInterface $entity) { */ public function handleEntityDelete(EntityInterface $entity) { if ($this->isStorageAvailable() && $this->isLayoutCompatibleEntity($entity)) { - $this->usage->removeByLayoutEntity($entity); + $entity_type = $this->entityTypeManager->getDefinition($entity->getEntityTypeId()); + if (!$entity_type->getDataTable()) { + // If the entity type does not have a data table we cannot find unused + // blocks on cron. + $block_storage = $this->entityTypeManager->getStorage('block_content'); + $query = $block_storage->getQuery(); + $query->condition('parent_entity_id', $entity->id()); + $query->condition('parent_entity_type', $entity->getEntityTypeId()); + $block_ids = $query->execute(); + foreach ($block_ids as $block_id) { + /** @var \Drupal\block_content\BlockContentInterface $block */ + $block = $block_storage->load($block_id); + $block->set('parent_entity_id', NULL); + $block->save(); + } + } } } @@ -178,12 +191,7 @@ public function handlePreSave(EntityInterface $entity) { foreach ($this->getInlineBlockComponents($sections) as $component) { /** @var \Drupal\layout_builder\Plugin\Block\InlineBlockContentBlock $plugin */ $plugin = $component->getPlugin(); - $pre_save_configuration = $plugin->getConfiguration(); - $plugin->saveBlockContent($new_revision, $duplicate_blocks); - $post_save_configuration = $plugin->getConfiguration(); - if ($duplicate_blocks || (empty($pre_save_configuration['block_revision_id']) && !empty($post_save_configuration['block_revision_id']))) { - $this->usage->addUsage($this->getPluginBlockId($plugin), $entity->getEntityTypeId(), $entity->id()); - } + $plugin->saveBlockContent($entity, $new_revision, $duplicate_blocks); $component->setConfiguration($plugin->getConfiguration()); } } @@ -256,13 +264,12 @@ protected function getPluginBlockId(PluginInspectionInterface $plugin) { * * @throws \Drupal\Core\Entity\EntityStorageException */ - protected function deleteBlocksAndUsage(array $block_content_ids) { + protected function deleteBlocks(array $block_content_ids) { foreach ($block_content_ids as $block_content_id) { if ($block = $this->storage->load($block_content_id)) { $block->delete(); } } - $this->usage->deleteUsage($block_content_ids); } /** @@ -275,7 +282,7 @@ protected function deleteBlocksAndUsage(array $block_content_ids) { */ public function removeUnused($limit = 100) { if ($this->isStorageAvailable()) { - $this->deleteBlocksAndUsage($this->usage->getUnused($limit)); + $this->deleteBlocks($this->getUnused($limit)); } } @@ -332,4 +339,55 @@ protected function getBlockIdsForRevisionIds(array $revision_ids) { } + /** + * Get unused IDs of blocks. + * + * @param int $limit + * The limit of block IDs to return. + * + * @return int[] + * The block IDs. + */ + protected function getUnused($limit) { + $block_type_definition = $this->entityTypeManager->getDefinition('block_content'); + $data_table = $block_type_definition->getDataTable(); + $query = Database::getConnection()->select($data_table); + $query->distinct(TRUE); + $query->isNotNull('parent_entity_type'); + $query->fields($data_table, ['parent_entity_type']); + $parent_entity_types = $query->execute()->fetchCol(); + $block_id_key = $block_type_definition->getKey('id'); + $block_ids = []; + foreach ($parent_entity_types as $parent_entity_type) { + $parent_type_definition = $this->entityTypeManager->getDefinition($parent_entity_type); + if ($parent_data_table = $parent_type_definition->getDataTable()) { + $sub_query = Database::getConnection()->select($parent_data_table, 'parent'); + $parent_id_key = $parent_type_definition->getKey('id'); + $sub_query->fields('parent', [$parent_id_key]); + $sub_query->where("blocks.parent_entity_id = parent.$parent_id_key"); + + $query = Database::getConnection()->select($data_table, 'blocks'); + $query->fields('blocks', [$block_id_key]); + $query->isNotNull('parent_entity_id'); + $query->condition('parent_entity_type', $parent_entity_type); + $query->notExists($sub_query); + $query->range(0, $limit - count($block_ids)); + $new_block_ids = $query->execute()->fetchCol(); + } + else { + // @todo Handle parent types with no data table. + $block_query = $this->entityTypeManager->getStorage('block_content')->getQuery(); + $block_query->condition('parent_entity_type', $parent_entity_type); + $block_query->notExists('parent_entity_id'); + $block_query->range(0, $limit - count($block_ids)); + $new_block_ids = $block_query->execute(); + } + $block_ids = array_merge($block_ids, $new_block_ids); + if (count($block_ids) > 50) { + break; + } + } + return $block_ids; + } + } diff --git a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php index fc3818bf3109..181ed8229b6c 100644 --- a/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php +++ b/core/modules/layout_builder/src/EventSubscriber/BlockComponentRenderArray.php @@ -2,7 +2,6 @@ namespace Drupal\layout_builder\EventSubscriber; -use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Access\AccessResult; use Drupal\Core\Block\BlockPluginInterface; use Drupal\Core\Session\AccountInterface; @@ -57,18 +56,6 @@ public function onBuildRender(SectionComponentBuildRenderArrayEvent $event) { return; } - // Set block access dependency even if we are not checking access on - // this level. The block itself may render another AccessDependentInterface - // object and need to pass on this value. - if ($block instanceof AccessDependentInterface) { - $contexts = $event->getContexts(); - if (isset($contexts['layout_builder.entity'])) { - if ($entity = $contexts['layout_builder.entity']->getContextValue()) { - $block->setAccessDependency($entity); - } - } - } - // Only check access if the component is not being previewed. if ($event->inPreview()) { $access = AccessResult::allowed()->setCacheMaxAge(0); diff --git a/core/modules/layout_builder/src/InlineBlockContentUsage.php b/core/modules/layout_builder/src/InlineBlockContentUsage.php deleted file mode 100644 index 9166a79c0d42..000000000000 --- a/core/modules/layout_builder/src/InlineBlockContentUsage.php +++ /dev/null @@ -1,96 +0,0 @@ -connection = $connection; - } - - /** - * Add a usage record. - * - * @param int $block_content_id - * The block content id. - * @param string $layout_entity_type - * The layout entity type. - * @param string $layout_entity_id - * The layout entity id. - * - * @throws \Exception - */ - public function addUsage($block_content_id, $layout_entity_type, $layout_entity_id) { - $this->connection->merge('inline_block_content_usage') - ->keys([ - 'block_content_id' => $block_content_id, - 'layout_entity_id' => $layout_entity_id, - 'layout_entity_type' => $layout_entity_type, - ])->execute(); - } - - /** - * Gets unused inline block content IDs. - * - * @param int $limit - * The maximum number of block content entity IDs to return. - * - * @return int[] - * The entity IDs. - */ - public function getUnused($limit = 100) { - $query = $this->connection->select('inline_block_content_usage', 't'); - $query->fields('t', ['block_content_id']); - $query->isNull('layout_entity_id'); - $query->isNull('layout_entity_type'); - return $query->range(0, $limit)->execute()->fetchCol(); - } - - /** - * Remove usage record by layout entity. - * - * @param \Drupal\Core\Entity\EntityInterface $entity - * The layout entity. - */ - public function removeByLayoutEntity(EntityInterface $entity) { - $query = $this->connection->update('inline_block_content_usage') - ->fields([ - 'layout_entity_type' => NULL, - 'layout_entity_id' => NULL, - ]); - $query->condition('layout_entity_type', $entity->getEntityTypeId()); - $query->condition('layout_entity_id', $entity->id()); - $query->execute(); - } - - /** - * Delete the content blocks and delete the usage records. - * - * @param int[] $block_content_ids - * The block content entity IDs. - */ - public function deleteUsage(array $block_content_ids) { - $query = $this->connection->delete('inline_block_content_usage')->condition('block_content_id', $block_content_ids, 'IN'); - $query->execute(); - } - -} diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php index 9ab2d26ec3a1..27712528a5cf 100644 --- a/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php @@ -9,6 +9,7 @@ use Drupal\Core\Block\BlockBase; use Drupal\Core\Entity\Entity\EntityFormDisplay; use Drupal\Core\Entity\EntityDisplayRepositoryInterface; +use Drupal\Core\Entity\EntityInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Form\SubformStateInterface; @@ -26,9 +27,7 @@ * deriver = "Drupal\layout_builder\Plugin\Derivative\InlineBlockContentDeriver", * ) */ -class InlineBlockContentBlock extends BlockBase implements ContainerFactoryPluginInterface, AccessDependentInterface { - - use AccessDependentTrait; +class InlineBlockContentBlock extends BlockBase implements ContainerFactoryPluginInterface { /** * The entity type manager service. @@ -228,9 +227,6 @@ protected function getEntity() { ]); } } - if ($this->blockContent instanceof AccessDependentInterface && $dependee = $this->getAccessDependency()) { - $this->blockContent->setAccessDependency($dependee); - } return $this->blockContent; } @@ -252,6 +248,8 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta /** * Saves the "block_content" entity for this plugin. * + * @param \Drupal\Core\Entity\EntityInterface $parent_entity + * The parent entity. * @param bool $new_revision * Whether to create new revision. * @param bool $duplicate_block @@ -261,7 +259,7 @@ public function buildConfigurationForm(array $form, FormStateInterface $form_sta * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException * @throws \Drupal\Core\Entity\EntityStorageException */ - public function saveBlockContent($new_revision = FALSE, $duplicate_block = FALSE) { + public function saveBlockContent(EntityInterface $parent_entity, $new_revision = FALSE, $duplicate_block = FALSE) { /** @var \Drupal\block_content\BlockContentInterface $block */ $block = NULL; if ($duplicate_block && !empty($this->configuration['block_revision_id']) && empty($this->configuration['block_serialized'])) { @@ -275,6 +273,7 @@ public function saveBlockContent($new_revision = FALSE, $duplicate_block = FALSE } } if ($block) { + $block->setParentEntity($parent_entity); if ($new_revision) { $block->setNewRevision(); } From d6cb2a8aa9d9c114cdd74463c84eee7a6bd642d5 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Wed, 20 Jun 2018 23:41:54 -0400 Subject: [PATCH 25/39] clean up block_content module --- core/modules/block_content/block_content.install | 8 -------- .../src/BlockContentAccessControlHandler.php | 11 ++++++++--- .../block_content/src/BlockContentInterface.php | 1 - .../block_content/src/BlockContentListBuilder.php | 5 ++--- .../block_content/src/Entity/BlockContent.php | 12 ++++++------ .../src/Plugin/Derivative/BlockContent.php | 2 +- .../config/install/views.view.block_content.yml | 2 -- .../Update/BlockContentParentEntityUpdateTest.php | 11 ++++++++++- 8 files changed, 27 insertions(+), 25 deletions(-) diff --git a/core/modules/block_content/block_content.install b/core/modules/block_content/block_content.install index e8eb2fbeef2d..e8ee49260e58 100644 --- a/core/modules/block_content/block_content.install +++ b/core/modules/block_content/block_content.install @@ -164,11 +164,3 @@ function block_content_update_8600() { $update_manager->installFieldStorageDefinition('parent_entity_id', 'block_content', 'block_content', $parent_entity_id); } - - -/** - * dfdf - */ -function block_content_update_8601() { - -} diff --git a/core/modules/block_content/src/BlockContentAccessControlHandler.php b/core/modules/block_content/src/BlockContentAccessControlHandler.php index fc926a001509..34077b82af5f 100644 --- a/core/modules/block_content/src/BlockContentAccessControlHandler.php +++ b/core/modules/block_content/src/BlockContentAccessControlHandler.php @@ -26,9 +26,14 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter $access = parent::checkAccess($entity, $operation, $account); } /** @var \Drupal\block_content\BlockContentInterface $entity */ - if ($parent_entity = $entity->getParentEntity()) { - $parent_access = $parent_entity->access($operation, $account, TRUE); - $access = $access->andIf($parent_access)->addCacheableDependency($entity); + if ($entity->hasParentEntity()) { + if ($parent_entity = $entity->getParentEntity()) { + $access = $access->andIf($parent_entity->access($operation, $account, TRUE))->addCacheableDependency($entity); + } + else { + // The entity has a parent but it was not able to be loaded. + return AccessResult::forbidden('Parent entity not available.')->addCacheableDependency($entity); + } } return $access; } diff --git a/core/modules/block_content/src/BlockContentInterface.php b/core/modules/block_content/src/BlockContentInterface.php index fac092f2fd6b..16d427561e20 100644 --- a/core/modules/block_content/src/BlockContentInterface.php +++ b/core/modules/block_content/src/BlockContentInterface.php @@ -2,7 +2,6 @@ namespace Drupal\block_content; -use Drupal\Core\Access\AccessDependentInterface; use Drupal\Core\Entity\ContentEntityInterface; use Drupal\Core\Entity\EntityChangedInterface; use Drupal\Core\Entity\EntityInterface; diff --git a/core/modules/block_content/src/BlockContentListBuilder.php b/core/modules/block_content/src/BlockContentListBuilder.php index 3b37dfd51ccd..e689258d9675 100644 --- a/core/modules/block_content/src/BlockContentListBuilder.php +++ b/core/modules/block_content/src/BlockContentListBuilder.php @@ -33,9 +33,8 @@ public function buildRow(EntityInterface $entity) { */ protected function getEntityIds() { $query = $this->getStorage()->getQuery() - ->sort($this->entityType->getKey('id')); - - $query->notExists('parent_entity_id'); + ->sort($this->entityType->getKey('id')) + ->notExists('parent_entity_id'); // Only add the pager if a limit is specified. if ($this->limit) { diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index e5c15f88cc78..605cb25fdddc 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -131,8 +131,8 @@ public static function postDelete(EntityStorageInterface $storage, array $entiti parent::postDelete($storage, $entities); /** @var \Drupal\block_content\BlockContentInterface $block */ foreach ($entities as $block) { - if (empty($block->get('parent_entity_id'))) { - // If any deleted blocks do not have a parent id clear the block cache. + if (!$block->hasParentEntity()) { + // If any deleted blocks do not have a parent ID clear the block cache. static::invalidateBlockPluginCache(); return; } @@ -215,16 +215,16 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setDescription(t('The parent entity type.')) ->setTranslatable(FALSE) ->setRevisionable(FALSE) - ->setDefaultValue('') - ->setInitialValue(''); + ->setDefaultValue(NULL) + ->setInitialValue(NULL); $fields['parent_entity_id'] = BaseFieldDefinition::create('string') ->setLabel(t('Parent ID')) ->setDescription(t('The parent entity ID.')) ->setTranslatable(FALSE) ->setRevisionable(FALSE) - ->setDefaultValue('') - ->setInitialValue(''); + ->setDefaultValue(NULL) + ->setInitialValue(NULL); return $fields; } diff --git a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php index a650660f9a6a..a6444eb94539 100644 --- a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php +++ b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php @@ -47,7 +47,7 @@ public function getDerivativeDefinitions($base_plugin_definition) { $block_contents = $this->blockContentStorage->loadMultiple($block_ids); // Reset the discovered definitions. $this->derivatives = []; - /** @var $block_content \Drupal\block_content\Entity\BlockContent */ + /* @var $block_content \Drupal\block_content\Entity\BlockContent */ foreach ($block_contents as $block_content) { $this->derivatives[$block_content->uuid()] = $base_plugin_definition; $this->derivatives[$block_content->uuid()]['admin_label'] = $block_content->label(); diff --git a/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml b/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml index a3cc40607794..1c95f9f854cb 100644 --- a/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml +++ b/core/modules/block_content/tests/modules/block_content_view_override/config/install/views.view.block_content.yml @@ -4,8 +4,6 @@ dependencies: module: - block_content - user -_core: - default_config_hash: gkRJCqHr3uSO8ALHLatX-7YKfX0lWEgkC5qMBtCf_Sg id: block_content label: 'Custom block library' module: views diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php index 3ffeab7c01dc..bd7cf0d771ca 100644 --- a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php @@ -44,13 +44,22 @@ public function testParentFieldsAddition() { // Run updates. $this->runUpdates(); - // Check that the field exists and is configured correctly. + // Check that the 'parent_entity_type' field exists and is configured + // correctly. $parent_type_field = $entity_definition_update_manager->getFieldStorageDefinition('parent_entity_type', 'block_content'); $this->assertEquals('Parent entity type', $parent_type_field->getLabel()); $this->assertEquals('The parent entity type.', $parent_type_field->getDescription()); $this->assertEquals(FALSE, $parent_type_field->isRevisionable()); $this->assertEquals(FALSE, $parent_type_field->isTranslatable()); + // Check that the 'parent_entity_id' field exists and is configured + // correctly. + $parent_id_field = $entity_definition_update_manager->getFieldStorageDefinition('parent_entity_id', 'block_content'); + $this->assertEquals('Parent ID', $parent_id_field->getLabel()); + $this->assertEquals('The parent entity ID.', $parent_id_field->getDescription()); + $this->assertEquals(FALSE, $parent_id_field->isRevisionable()); + $this->assertEquals(FALSE, $parent_id_field->isTranslatable()); + $after_block1 = BlockContent::create([ 'info' => 'After update block1', 'type' => 'basic_block', From 77fdae9d6a38c9bc1654eeeab7b2d0fa6dd78362 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 11:45:33 -0400 Subject: [PATCH 26/39] change entity type parent to determine if have parent --- .../block_content/block_content.module | 2 +- .../block_content.post_update.php | 2 +- .../optional/views.view.block_content.yml | 2 +- .../src/BlockContentListBuilder.php | 2 +- .../src/BlockContentViewsData.php | 4 +- .../block_content/src/Entity/BlockContent.php | 8 +- .../src/Plugin/Derivative/BlockContent.php | 2 +- .../src/Plugin/views/wizard/BlockContent.php | 2 +- .../Views/BlockContentWizardTest.php | 2 +- .../layout_builder/src/EntityOperations.php | 124 ++++++++++++------ 10 files changed, 100 insertions(+), 50 deletions(-) diff --git a/core/modules/block_content/block_content.module b/core/modules/block_content/block_content.module index cf6521bb4522..31435c176b88 100644 --- a/core/modules/block_content/block_content.module +++ b/core/modules/block_content/block_content.module @@ -132,7 +132,7 @@ function block_content_query_entity_reference_alter(AlterableInterface $query) { $data_table = \Drupal::entityTypeManager()->getDefinition('block_content')->getDataTable(); if (array_key_exists($data_table, $query->getTables()) && !_block_content_has_parent_entity_condition($query->conditions())) { // If no parent entity condition create a condition. - $query->isNull("$data_table.parent_entity_id"); + $query->isNull("$data_table.parent_entity_type"); } } } diff --git a/core/modules/block_content/block_content.post_update.php b/core/modules/block_content/block_content.post_update.php index 848f314d3825..d71f5758b299 100644 --- a/core/modules/block_content/block_content.post_update.php +++ b/core/modules/block_content/block_content.post_update.php @@ -31,7 +31,7 @@ function block_content_post_update_add_views_parent_filter() { ->set("$base.field", "has_parent") ->set("$base.value", '0') ->set("$base.entity_type", "block_content") - ->set("$base.entity_field", "parent_entity_id"); + ->set("$base.entity_field", "parent_entity_type"); } } $view->save(); diff --git a/core/modules/block_content/config/optional/views.view.block_content.yml b/core/modules/block_content/config/optional/views.view.block_content.yml index 14f652ac6ef3..b4de8c1859a3 100644 --- a/core/modules/block_content/config/optional/views.view.block_content.yml +++ b/core/modules/block_content/config/optional/views.view.block_content.yml @@ -467,7 +467,7 @@ display: default_group_multiple: { } group_items: { } entity_type: block_content - entity_field: parent_entity_id + entity_field: parent_entity_type plugin_id: boolean_string sorts: { } title: 'Custom block library' diff --git a/core/modules/block_content/src/BlockContentListBuilder.php b/core/modules/block_content/src/BlockContentListBuilder.php index e689258d9675..8467c3c3e003 100644 --- a/core/modules/block_content/src/BlockContentListBuilder.php +++ b/core/modules/block_content/src/BlockContentListBuilder.php @@ -34,7 +34,7 @@ public function buildRow(EntityInterface $entity) { protected function getEntityIds() { $query = $this->getStorage()->getQuery() ->sort($this->entityType->getKey('id')) - ->notExists('parent_entity_id'); + ->notExists('parent_entity_type'); // Only add the pager if a limit is specified. if ($this->limit) { diff --git a/core/modules/block_content/src/BlockContentViewsData.php b/core/modules/block_content/src/BlockContentViewsData.php index f7eecc598e40..de3df7af6ad6 100644 --- a/core/modules/block_content/src/BlockContentViewsData.php +++ b/core/modules/block_content/src/BlockContentViewsData.php @@ -29,8 +29,8 @@ public function getViewsData() { 'id' => 'boolean_string', 'accept_null' => TRUE, ], - 'entity field' => 'parent_entity_id', - 'real field' => 'parent_entity_id', + 'entity field' => 'parent_entity_type', + 'real field' => 'parent_entity_type', ]; $data['block_content_field_data']['type']['field']['id'] = 'field'; diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index 605cb25fdddc..ba5002f63412 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -119,7 +119,7 @@ public function getTheme() { */ public function postSave(EntityStorageInterface $storage, $update = TRUE) { parent::postSave($storage, $update); - if (empty($this->get('parent_entity_id')->value) || (isset($this->original) && empty($this->original->get('parent_entity_id')->value))) { + if (empty($this->get('parent_entity_type')->value) || (isset($this->original) && empty($this->original->get('parent_entity_type')->value))) { static::invalidateBlockPluginCache(); } } @@ -210,6 +210,8 @@ public static function baseFieldDefinitions(EntityTypeInterface $entity_type) { ->setTranslatable(TRUE) ->setRevisionable(TRUE); + // @todo Is there a core issue to add + // https://www.drupal.org/project/dynamic_entity_reference $fields['parent_entity_type'] = BaseFieldDefinition::create('string') ->setLabel(t('Parent entity type')) ->setDescription(t('The parent entity type.')) @@ -348,6 +350,8 @@ public function removeParentEntity() { * {@inheritdoc} */ public function hasParentEntity() { - return !empty($this->get('parent_entity_type')->value) && !empty($this->get('parent_entity_id')->value); + // If either parent field value is set then the block is considered to have + // a parent. + return !empty($this->get('parent_entity_type')->value) || !empty($this->get('parent_entity_id')->value); } } diff --git a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php index a6444eb94539..edf82bc46a59 100644 --- a/core/modules/block_content/src/Plugin/Derivative/BlockContent.php +++ b/core/modules/block_content/src/Plugin/Derivative/BlockContent.php @@ -43,7 +43,7 @@ public static function create(ContainerInterface $container, $base_plugin_id) { * {@inheritdoc} */ public function getDerivativeDefinitions($base_plugin_definition) { - $block_ids = $this->blockContentStorage->getQuery()->notExists('parent_entity_id')->execute(); + $block_ids = $this->blockContentStorage->getQuery()->notExists('parent_entity_type')->execute(); $block_contents = $this->blockContentStorage->loadMultiple($block_ids); // Reset the discovered definitions. $this->derivatives = []; diff --git a/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php b/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php index 84c13d6be83c..6c4bfcf33696 100644 --- a/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php +++ b/core/modules/block_content/src/Plugin/views/wizard/BlockContent.php @@ -28,7 +28,7 @@ public function getFilters() { 'operator' => '=', 'value' => '0', 'entity_type' => $this->entityTypeId, - 'entity_field' => 'parent_entity_id', + 'entity_field' => 'parent_entity_type', ]; return $filters; } diff --git a/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php b/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php index d6d5425b1959..e765dd6684d4 100644 --- a/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php +++ b/core/modules/block_content/tests/src/Functional/Views/BlockContentWizardTest.php @@ -44,7 +44,7 @@ public function testViewAddBlockContent() { $display_options = $view->getDisplay('default')['display_options']; $this->assertEquals('block_content', $display_options['filters']['has_parent']['entity_type']); - $this->assertEquals('parent_entity_id', $display_options['filters']['has_parent']['entity_field']); + $this->assertEquals('parent_entity_type', $display_options['filters']['has_parent']['entity_field']); $this->assertEquals('boolean_string', $display_options['filters']['has_parent']['plugin_id']); $this->assertEquals('0', $display_options['filters']['has_parent']['value']); } diff --git a/core/modules/layout_builder/src/EntityOperations.php b/core/modules/layout_builder/src/EntityOperations.php index 4a15c4b67188..c4f13fdd718f 100644 --- a/core/modules/layout_builder/src/EntityOperations.php +++ b/core/modules/layout_builder/src/EntityOperations.php @@ -6,6 +6,7 @@ use Drupal\Core\Database\Database; use Drupal\Core\DependencyInjection\ContainerInjectionInterface; use Drupal\Core\Entity\EntityInterface; +use Drupal\Core\Entity\EntityTypeInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\Entity\FieldableEntityInterface; use Drupal\Core\Entity\RevisionableInterface; @@ -102,13 +103,17 @@ protected function removeUnusedForEntityOnSave(EntityInterface $entity) { * * @param \Drupal\Core\Entity\EntityInterface $entity * The parent entity. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException + * @throws \Drupal\Core\Entity\EntityStorageException */ public function handleEntityDelete(EntityInterface $entity) { if ($this->isStorageAvailable() && $this->isLayoutCompatibleEntity($entity)) { - $entity_type = $this->entityTypeManager->getDefinition($entity->getEntityTypeId()); - if (!$entity_type->getDataTable()) { - // If the entity type does not have a data table we cannot find unused - // blocks on cron. + if (!$this->isUsingDataTables($entity->getEntityTypeId())) { + // If either entity type does not have a data table we cannot find + // unused blocks in '::removeUnused()'. + // @see ::getUnusedBlockIdsForEntityWithDataTable $block_storage = $this->entityTypeManager->getStorage('block_content'); $query = $block_storage->getQuery(); $query->condition('parent_entity_id', $entity->id()); @@ -278,6 +283,8 @@ protected function deleteBlocks(array $block_content_ids) { * @param int $limit * The maximum number of block content entities to remove. * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException * @throws \Drupal\Core\Entity\EntityStorageException */ public function removeUnused($limit = 100) { @@ -347,47 +354,86 @@ protected function getBlockIdsForRevisionIds(array $revision_ids) { * * @return int[] * The block IDs. + * + * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException + * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ protected function getUnused($limit) { - $block_type_definition = $this->entityTypeManager->getDefinition('block_content'); - $data_table = $block_type_definition->getDataTable(); - $query = Database::getConnection()->select($data_table); - $query->distinct(TRUE); - $query->isNotNull('parent_entity_type'); - $query->fields($data_table, ['parent_entity_type']); - $parent_entity_types = $query->execute()->fetchCol(); - $block_id_key = $block_type_definition->getKey('id'); $block_ids = []; - foreach ($parent_entity_types as $parent_entity_type) { - $parent_type_definition = $this->entityTypeManager->getDefinition($parent_entity_type); - if ($parent_data_table = $parent_type_definition->getDataTable()) { - $sub_query = Database::getConnection()->select($parent_data_table, 'parent'); - $parent_id_key = $parent_type_definition->getKey('id'); - $sub_query->fields('parent', [$parent_id_key]); - $sub_query->where("blocks.parent_entity_id = parent.$parent_id_key"); - - $query = Database::getConnection()->select($data_table, 'blocks'); - $query->fields('blocks', [$block_id_key]); - $query->isNotNull('parent_entity_id'); - $query->condition('parent_entity_type', $parent_entity_type); - $query->notExists($sub_query); - $query->range(0, $limit - count($block_ids)); - $new_block_ids = $query->execute()->fetchCol(); - } - else { - // @todo Handle parent types with no data table. - $block_query = $this->entityTypeManager->getStorage('block_content')->getQuery(); - $block_query->condition('parent_entity_type', $parent_entity_type); - $block_query->notExists('parent_entity_id'); - $block_query->range(0, $limit - count($block_ids)); - $new_block_ids = $block_query->execute(); - } - $block_ids = array_merge($block_ids, $new_block_ids); - if (count($block_ids) > 50) { - break; + foreach ($this->entityTypeManager->getDefinitions() as $definition) { + if ($this->entityTypeSupportsLayouts($definition)) { + if ($this->isUsingDataTables($definition->id())) { + $new_block_ids = $this->getUnusedBlockIdsForEntityWithDataTable($limit - count($block_ids), $definition); + } + else { + $block_query = $this->entityTypeManager->getStorage('block_content')->getQuery(); + $block_query->condition('parent_entity_type', $definition->id()); + $block_query->notExists('parent_entity_id'); + $block_query->range(0, $limit - count($block_ids)); + $new_block_ids = $block_query->execute(); + } + $block_ids = array_merge($block_ids, $new_block_ids); + if (count($block_ids) >= $limit) { + break; + } } + } return $block_ids; } + /** + * Gets the unused block IDs for an entity type using a datatable. + * + * @param int $limit + * The limit of number of block IDs to retrieve. + * @param \Drupal\Core\Entity\EntityTypeInterface $parent_type_definition + * The parent entity type definition. + * + * @return int[] + * The block IDs. + */ + protected function getUnusedBlockIdsForEntityWithDataTable($limit, EntityTypeInterface $parent_type_definition) { + $block_type_definition = $this->entityTypeManager->getDefinition('block_content'); + $sub_query = Database::getConnection() + ->select($parent_type_definition->getDataTable(), 'parent'); + $parent_id_key = $parent_type_definition->getKey('id'); + $sub_query->fields('parent', [$parent_id_key]); + $sub_query->where("blocks.parent_entity_id = parent.$parent_id_key"); + + $query = Database::getConnection()->select($block_type_definition->getDataTable(), 'blocks'); + $query->fields('blocks', [$block_type_definition->getKey('id')]); + $query->isNotNull('parent_entity_id'); + $query->condition('parent_entity_type', $parent_type_definition->id()); + $query->notExists($sub_query); + $query->range(0, $limit); + return $query->execute()->fetchCol(); + } + + /** + * Determines if parent entity and 'block_content' type are using datatables. + * + * @param string $parent_entity_type_id + * The parent entity type. + * + * @return bool + * TRUE if the parent entity and 'block_content' are using datatables. + */ + protected function isUsingDataTables($parent_entity_type_id) { + return $this->entityTypeManager->getDefinition($parent_entity_type_id)->getDataTable() && $this->entityTypeManager->getDefinition('block_content')->getDataTable(); + } + + /** + * Checks if the entity type supports layouts. + * + * @param \Drupal\Core\Entity\EntityTypeInterface $definition + * The entity type definition. + * + * @return bool + * TRUE if the entity type supports layouts. + */ + protected function entityTypeSupportsLayouts(EntityTypeInterface $definition) { + return $definition->id() === 'entity_view_display' || array_search(FieldableEntityInterface::class, class_implements($definition->getClass())) !== FALSE; + } + } From da38813afcb7b0ba2b2cd0abcadc248b2b8a267a Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 13:20:50 -0400 Subject: [PATCH 27/39] remove AccessDependent logic --- .../src/Plugin/Block/InlineBlockContentBlock.php | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php index 27712528a5cf..2496eae99470 100644 --- a/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php +++ b/core/modules/layout_builder/src/Plugin/Block/InlineBlockContentBlock.php @@ -3,8 +3,6 @@ namespace Drupal\layout_builder\Plugin\Block; use Drupal\Component\Utility\NestedArray; -use Drupal\Core\Access\AccessDependentInterface; -use Drupal\Core\Access\AccessDependentTrait; use Drupal\Core\Access\AccessResult; use Drupal\Core\Block\BlockBase; use Drupal\Core\Entity\Entity\EntityFormDisplay; @@ -223,7 +221,6 @@ protected function getEntity() { else { $this->blockContent = $this->entityTypeManager->getStorage('block_content')->create([ 'type' => $this->getDerivativeId(), - 'reusable' => FALSE, ]); } } From 24bc39b3d1f17eb8b5af3c575c41e7f30274d9d4 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 13:37:57 -0400 Subject: [PATCH 28/39] add todo --- .../FunctionalJavascript/InlineBlockContentBlockTest.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php index 04ee6e64ed0d..7b5f486239a2 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php @@ -404,12 +404,15 @@ public function testDeletion() { // Remove block from override. // Currently revisions are not actually created so this check will not pass. // @see https://www.drupal.org/node/2937199 - /*$this->removeInlineBlockFromLayout(); + // @todo Uncomment this portion when fixed. + /* + $this->removeInlineBlockFromLayout(); $this->assertSaveLayout(); $cron->run(); // Ensure entity block is not deleted because it is needed in revision. $this->assertNotEmpty($this->blockStorage->load($node_1_block_id)); - $this->assertCount(2, $this->blockStorage->loadMultiple());*/ + $this->assertCount(2, $this->blockStorage->loadMultiple()); + */ // Ensure entity block is deleted when node is deleted. $this->drupalGet('node/1/delete'); From 321dc1139545799c90e53d0bb479bf5508de25be Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 15:12:54 -0400 Subject: [PATCH 29/39] update comments --- core/modules/layout_builder/src/EntityOperations.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/core/modules/layout_builder/src/EntityOperations.php b/core/modules/layout_builder/src/EntityOperations.php index c4f13fdd718f..472b77072e26 100644 --- a/core/modules/layout_builder/src/EntityOperations.php +++ b/core/modules/layout_builder/src/EntityOperations.php @@ -111,9 +111,9 @@ protected function removeUnusedForEntityOnSave(EntityInterface $entity) { public function handleEntityDelete(EntityInterface $entity) { if ($this->isStorageAvailable() && $this->isLayoutCompatibleEntity($entity)) { if (!$this->isUsingDataTables($entity->getEntityTypeId())) { - // If either entity type does not have a data table we cannot find - // unused blocks in '::removeUnused()'. - // @see ::getUnusedBlockIdsForEntityWithDataTable + // If either entity type does not have a data table we need to remove + // 'parent_entity_id' so that we are able to find the entities to delete + // in ::getUnused(). $block_storage = $this->entityTypeManager->getStorage('block_content'); $query = $block_storage->getQuery(); $query->condition('parent_entity_id', $entity->id()); @@ -366,6 +366,9 @@ protected function getUnused($limit) { $new_block_ids = $this->getUnusedBlockIdsForEntityWithDataTable($limit - count($block_ids), $definition); } else { + // For parent entity types that don't use a datatable we remove + // 'parent_entity_id' on entity delete. + // @see ::handleEntityDelete() $block_query = $this->entityTypeManager->getStorage('block_content')->getQuery(); $block_query->condition('parent_entity_type', $definition->id()); $block_query->notExists('parent_entity_id'); From 2fa0be077905fd85d675bd5e689b33ff247227f6 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 15:23:58 -0400 Subject: [PATCH 30/39] update comments --- core/modules/layout_builder/src/EntityOperations.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/core/modules/layout_builder/src/EntityOperations.php b/core/modules/layout_builder/src/EntityOperations.php index 472b77072e26..70f222ba373c 100644 --- a/core/modules/layout_builder/src/EntityOperations.php +++ b/core/modules/layout_builder/src/EntityOperations.php @@ -99,7 +99,7 @@ protected function removeUnusedForEntityOnSave(EntityInterface $entity) { } /** - * Handles entity tracking on deleting a parent entity. + * Handles reacting to a deleting a parent entity. * * @param \Drupal\Core\Entity\EntityInterface $entity * The parent entity. @@ -113,7 +113,11 @@ public function handleEntityDelete(EntityInterface $entity) { if (!$this->isUsingDataTables($entity->getEntityTypeId())) { // If either entity type does not have a data table we need to remove // 'parent_entity_id' so that we are able to find the entities to delete - // in ::getUnused(). + // in ::getUnused(). We can not delete the actuall entites here because + // it may take too long in a single request. Also the 'block_content' + // entities may also have layout overrides that have their own inline + // block entities. Therefore deleting the block entities here could + // trigger a cascade delete. $block_storage = $this->entityTypeManager->getStorage('block_content'); $query = $block_storage->getQuery(); $query->condition('parent_entity_id', $entity->id()); From 02757d595b3281b25462f68e05cfbd2e11e47062 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 15:38:15 -0400 Subject: [PATCH 31/39] remove debug --- .../src/Functional/Update/BlockContentParentEntityUpdateTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php index bd7cf0d771ca..5ad88936c635 100644 --- a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php @@ -100,7 +100,6 @@ public function testParentFieldsAddition() { // Ensure the Custom Block view shows the blocks without parents only. $this->drupalGet('admin/structure/block/block-content'); - file_put_contents('/Users/ted.bowman/Sites/www/test.html', $this->getSession()->getPage()->getOuterHtml()); $assert_session->statusCodeEquals('200'); $assert_session->responseContains('view-id-block_content'); $assert_session->pageTextContains($after_block1->label()); From 2724299a82e080e9d5e8ef613a3ea7646522161f Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 16:10:46 -0400 Subject: [PATCH 32/39] correct default values --- core/modules/block_content/block_content.install | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/core/modules/block_content/block_content.install b/core/modules/block_content/block_content.install index e8ee49260e58..c59f3fd25855 100644 --- a/core/modules/block_content/block_content.install +++ b/core/modules/block_content/block_content.install @@ -149,8 +149,8 @@ function block_content_update_8600() { ->setDescription(t('The parent entity type.')) ->setTranslatable(FALSE) ->setRevisionable(FALSE) - ->setDefaultValue('') - ->setInitialValue(''); + ->setDefaultValue(NULL) + ->setInitialValue(NULL); $update_manager->installFieldStorageDefinition('parent_entity_type', 'block_content', 'block_content', $parent_entity_type); @@ -159,8 +159,8 @@ function block_content_update_8600() { ->setDescription(t('The parent entity ID.')) ->setTranslatable(FALSE) ->setRevisionable(FALSE) - ->setDefaultValue('') - ->setInitialValue(''); + ->setDefaultValue(NULL) + ->setInitialValue(NULL); $update_manager->installFieldStorageDefinition('parent_entity_id', 'block_content', 'block_content', $parent_entity_id); } From 753d20356a69041b3bfc43c9088e5a49ae4673b1 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 16:36:16 -0400 Subject: [PATCH 33/39] sniff fix --- core/modules/block_content/src/Entity/BlockContent.php | 1 + .../Functional/Update/BlockContentParentEntityUpdateTest.php | 2 -- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/core/modules/block_content/src/Entity/BlockContent.php b/core/modules/block_content/src/Entity/BlockContent.php index ba5002f63412..2a8347703633 100644 --- a/core/modules/block_content/src/Entity/BlockContent.php +++ b/core/modules/block_content/src/Entity/BlockContent.php @@ -354,4 +354,5 @@ public function hasParentEntity() { // a parent. return !empty($this->get('parent_entity_type')->value) || !empty($this->get('parent_entity_id')->value); } + } diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php index 5ad88936c635..c93a840f2967 100644 --- a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php @@ -96,8 +96,6 @@ public function testParentFieldsAddition() { $this->assertEquals(TRUE, $block_with_parent->hasParentEntity()); $this->assertEquals(TRUE, $block2_with_parent->hasParentEntity()); - - // Ensure the Custom Block view shows the blocks without parents only. $this->drupalGet('admin/structure/block/block-content'); $assert_session->statusCodeEquals('200'); From 5f6457a27f1e063a18b9087a4c8815c1cbc60adc Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 16:46:44 -0400 Subject: [PATCH 34/39] fix space --- .../src/Functional/Update/BlockContentParentEntityUpdateTest.php | 1 - 1 file changed, 1 deletion(-) diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php index c93a840f2967..943dee34b77a 100644 --- a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php @@ -159,7 +159,6 @@ public function testParentFieldsAddition() { $this->drupalGet('block/' . $block_with_parent->id()); $assert_session->statusCodeEquals('403'); - } } From b3ed0e82611266638256bb5abc380819e743638b Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Thu, 21 Jun 2018 17:02:41 -0400 Subject: [PATCH 35/39] space --- .../src/FunctionalJavascript/InlineBlockContentBlockTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php index 7b5f486239a2..6f9077d9c8ef 100644 --- a/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php +++ b/core/modules/layout_builder/tests/src/FunctionalJavascript/InlineBlockContentBlockTest.php @@ -412,7 +412,7 @@ public function testDeletion() { // Ensure entity block is not deleted because it is needed in revision. $this->assertNotEmpty($this->blockStorage->load($node_1_block_id)); $this->assertCount(2, $this->blockStorage->loadMultiple()); - */ + */ // Ensure entity block is deleted when node is deleted. $this->drupalGet('node/1/delete'); From efb344f449bd59185eb528c6fa8221be2679933c Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Mon, 25 Jun 2018 08:28:49 -0400 Subject: [PATCH 36/39] Fix comment for query alter --- core/modules/block_content/block_content.module | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/core/modules/block_content/block_content.module b/core/modules/block_content/block_content.module index 31435c176b88..53b7dadbd325 100644 --- a/core/modules/block_content/block_content.module +++ b/core/modules/block_content/block_content.module @@ -115,15 +115,15 @@ function block_content_add_body_field($block_type_id, $label = 'Body') { * Alters any 'entity_reference' query where the entity type is * 'block_content' and the query has the tag 'block_content_access'. * - * These queries should only return with no parent blocks unless a condition on - * parent is explicitly set. + * These queries should only return blocks with no parents unless a condition on + * 'entity_parent_type' or 'entity_parent_id' is explicitly set. * - * Since block_content entities can be set to be have a parent they should by - * default not be selectable as entity reference values. A module can still - * create a instance of + * Block_content entities that have a parent should by default not be selectable + * as entity reference values. A module can still + * create an instance of * \Drupal\Core\Entity\EntityReferenceSelection\SelectionInterface * that will will allow selection of blocks with parents by explicitly setting - * a condition on either parent_entity_id or parent_entity_type fields. + * a condition on either the parent_entity_id or parent_entity_type fields. * * @see \Drupal\block_content\BlockContentAccessControlHandler */ From 9a2a6e2ad12220ef1c9633ffc508db1868e64761 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Mon, 25 Jun 2018 09:35:35 -0400 Subject: [PATCH 37/39] post_update use config updater service. --- .../block_content.post_update.php | 41 +++++++++++-------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/core/modules/block_content/block_content.post_update.php b/core/modules/block_content/block_content.post_update.php index d71f5758b299..cb3e9cd0e54f 100644 --- a/core/modules/block_content/block_content.post_update.php +++ b/core/modules/block_content/block_content.post_update.php @@ -5,35 +5,42 @@ * Post update functions for Custom Block. */ +use Drupal\Core\Config\Entity\ConfigEntityUpdater; + /** * Adds 'has_parent' filter to Custom Block views. */ -function block_content_post_update_add_views_parent_filter() { - $config_factory = \Drupal::configFactory(); +function block_content_post_update_add_views_parent_filter(&$sandbox = NULL) { $data_table = \Drupal::entityTypeManager() ->getDefinition('block_content') ->getDataTable(); - foreach ($config_factory->listAll('views.view.') as $view_config_name) { - $view = $config_factory->getEditable($view_config_name); + \Drupal::classResolver(ConfigEntityUpdater::class)->update($sandbox, 'view', function ($view) use ($data_table) { + /** @var \Drupal\views\ViewEntityInterface $view */ if ($view->get('base_table') != $data_table) { - continue; + return FALSE; } - foreach ($view->get('display') as $display_name => $display) { + $save_view = FALSE; + $displays = $view->get('display'); + foreach ($displays as $display_name => &$display) { // Update the default display and displays that have overridden filters. if (!isset($display['display_options']['filters']['has_parent']) && ($display_name === 'default' || isset($display['display_options']['filters']))) { - // Save off the base part of the config path we are updating. - $base = "display.$display_name.display_options.filters.has_parent"; - $view->set("$base.id", 'has_parent') - ->set("$base.plugin_id", 'boolean_string') - ->set("$base.table", $data_table) - ->set("$base.field", "has_parent") - ->set("$base.value", '0') - ->set("$base.entity_type", "block_content") - ->set("$base.entity_field", "parent_entity_type"); + $display['display_options']['filters']['has_parent'] = [ + 'id' => 'has_parent', + 'plugin_id' => 'boolean_string', + 'table' => $data_table, + 'field' => 'has_parent', + 'value' => '0', + 'entity_type' => 'block_content', + 'entity_field' => 'parent_entity_type', + ]; + $save_view = TRUE; } } - $view->save(); - } + if ($save_view) { + $view->set('display', $displays); + } + return $save_view; + }); } From d1f3b2d5a1e68424e3aca652abee468c5d7c57c6 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Mon, 25 Jun 2018 12:10:48 -0400 Subject: [PATCH 38/39] remove call to addCacheableDependency --- .../block_content/src/BlockContentAccessControlHandler.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/modules/block_content/src/BlockContentAccessControlHandler.php b/core/modules/block_content/src/BlockContentAccessControlHandler.php index 34077b82af5f..dc5b10737168 100644 --- a/core/modules/block_content/src/BlockContentAccessControlHandler.php +++ b/core/modules/block_content/src/BlockContentAccessControlHandler.php @@ -28,11 +28,11 @@ protected function checkAccess(EntityInterface $entity, $operation, AccountInter /** @var \Drupal\block_content\BlockContentInterface $entity */ if ($entity->hasParentEntity()) { if ($parent_entity = $entity->getParentEntity()) { - $access = $access->andIf($parent_entity->access($operation, $account, TRUE))->addCacheableDependency($entity); + $access = $access->andIf($parent_entity->access($operation, $account, TRUE)); } else { // The entity has a parent but it was not able to be loaded. - return AccessResult::forbidden('Parent entity not available.')->addCacheableDependency($entity); + $access = $access->andIf(AccessResult::forbidden('Parent entity not available.')); } } return $access; From a4f7706d54b06ce9891d3f44d3ed181bc49e2720 Mon Sep 17 00:00:00 2001 From: Ted Bowman Date: Mon, 25 Jun 2018 14:35:38 -0400 Subject: [PATCH 39/39] ensure state before parent update --- .../Update/BlockContentParentEntityUpdateTest.php | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php index 943dee34b77a..7a2ab3034cf7 100644 --- a/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php +++ b/core/modules/block_content/tests/src/Functional/Update/BlockContentParentEntityUpdateTest.php @@ -41,9 +41,24 @@ public function testParentFieldsAddition() { // 'info' field. $this->container->get('module_installer')->install(['block_content_view_override']); + // Ensure that parent entity fields are not present before updates. + $this->assertEmpty($entity_definition_update_manager->getFieldStorageDefinition('parent_entity_type', 'block_content')); + $this->assertEmpty($entity_definition_update_manager->getFieldStorageDefinition('parent_entity_id', 'block_content')); + + // Ensure that 'has_parent' filter is not present before updates. + $view_config = \Drupal::configFactory()->get('views.view.block_content'); + $this->assertFalse($view_config->isNew()); + $this->assertEmpty($view_config->get('display.default.display_options.filters.has_parent')); + $this->assertEmpty($view_config->get('display.page_2.display_options.filters.has_parent')); // Run updates. $this->runUpdates(); + // Ensure that 'has_parent' filter is present after updates. + \Drupal::configFactory()->clearStaticCache(); + $view_config = \Drupal::configFactory()->get('views.view.block_content'); + $this->assertNotEmpty($view_config->get('display.default.display_options.filters.has_parent')); + $this->assertNotEmpty($view_config->get('display.page_2.display_options.filters.has_parent')); + // Check that the 'parent_entity_type' field exists and is configured // correctly. $parent_type_field = $entity_definition_update_manager->getFieldStorageDefinition('parent_entity_type', 'block_content');