From e9500065dab1d076fc838c55bd537bc6c2dcc3f7 Mon Sep 17 00:00:00 2001 From: xjm <xjm@65776.no-reply.drupal.org> Date: Fri, 9 Dec 2016 11:55:52 +0000 Subject: [PATCH] Issue #2296423 by tim.plunkett, dsnopek, swentel, Manuel Garcia, tedbow, alexpott, xjm, andypost, dawehner, effulgentsia, Berdir, jhedstrom, catch, benjy, jibran, Wim Leers, tstoeckler, larowlan, webchick: Implement layout plugin type in core --- core/composer.json | 1 + core/config/schema/core.data_types.schema.yml | 7 + core/core.api.php | 11 + .../Drupal/Core/Layout/Annotation/Layout.php | 157 +++++ .../DerivablePluginDefinitionInterface.php | 42 ++ core/lib/Drupal/Core/Layout/LayoutDefault.php | 84 +++ .../Drupal/Core/Layout/LayoutDefinition.php | 552 ++++++++++++++++++ .../Drupal/Core/Layout/LayoutInterface.php | 38 ++ .../Core/Layout/LayoutPluginManager.php | 225 +++++++ .../Layout/LayoutPluginManagerInterface.php | 70 +++ ...nContainerDerivativeDiscoveryDecorator.php | 37 ++ .../ObjectDefinitionDiscoveryDecorator.php | 81 +++ .../layout_discovery.info.yml | 6 + .../layout_discovery/layout_discovery.install | 22 + .../layout_discovery/layout_discovery.module | 39 ++ .../layout_discovery.services.yml | 4 + .../templates/layout.html.twig | 18 + .../tests/src/Kernel/LayoutTest.php | 192 ++++++ .../config/schema/layout_test.schema.yml | 7 + .../layout_test/css/layout-test-2col.css | 16 + .../modules/layout_test/layout_test.info.yml | 6 + .../layout_test/layout_test.layouts.yml | 29 + .../layout_test/layout_test.libraries.yml | 5 + .../src/Plugin/Layout/LayoutTestPlugin.php | 61 ++ .../templates/layout-test-1col.html.twig | 14 + .../templates/layout-test-2col.html.twig | 14 + .../templates/layout-test-plugin.html.twig | 15 + .../Core/Layout/LayoutPluginManagerTest.php | 390 +++++++++++++ 28 files changed, 2143 insertions(+) create mode 100644 core/lib/Drupal/Core/Layout/Annotation/Layout.php create mode 100644 core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php create mode 100644 core/lib/Drupal/Core/Layout/LayoutDefault.php create mode 100644 core/lib/Drupal/Core/Layout/LayoutDefinition.php create mode 100644 core/lib/Drupal/Core/Layout/LayoutInterface.php create mode 100644 core/lib/Drupal/Core/Layout/LayoutPluginManager.php create mode 100644 core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php create mode 100644 core/lib/Drupal/Core/Layout/ObjectDefinitionContainerDerivativeDiscoveryDecorator.php create mode 100644 core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php create mode 100644 core/modules/layout_discovery/layout_discovery.info.yml create mode 100644 core/modules/layout_discovery/layout_discovery.install create mode 100644 core/modules/layout_discovery/layout_discovery.module create mode 100644 core/modules/layout_discovery/layout_discovery.services.yml create mode 100644 core/modules/layout_discovery/templates/layout.html.twig create mode 100644 core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php create mode 100644 core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml create mode 100644 core/modules/system/tests/modules/layout_test/css/layout-test-2col.css create mode 100644 core/modules/system/tests/modules/layout_test/layout_test.info.yml create mode 100644 core/modules/system/tests/modules/layout_test/layout_test.layouts.yml create mode 100644 core/modules/system/tests/modules/layout_test/layout_test.libraries.yml create mode 100644 core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php create mode 100644 core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig create mode 100644 core/modules/system/tests/modules/layout_test/templates/layout-test-2col.html.twig create mode 100644 core/modules/system/tests/modules/layout_test/templates/layout-test-plugin.html.twig create mode 100644 core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php diff --git a/core/composer.json b/core/composer.json index 7ae0ccf50330..99b5cd016d04 100644 --- a/core/composer.json +++ b/core/composer.json @@ -105,6 +105,7 @@ "drupal/image": "self.version", "drupal/inline_form_errors": "self.version", "drupal/language": "self.version", + "drupal/layout_discovery": "self.version", "drupal/link": "self.version", "drupal/locale": "self.version", "drupal/minimal": "self.version", diff --git a/core/config/schema/core.data_types.schema.yml b/core/config/schema/core.data_types.schema.yml index 73db361820f7..0fbeeedbe039 100644 --- a/core/config/schema/core.data_types.schema.yml +++ b/core/config/schema/core.data_types.schema.yml @@ -361,6 +361,13 @@ display_variant.plugin: type: string label: 'UUID' +layout_plugin.settings: + type: mapping + label: 'Layout settings' + +layout_plugin.settings.*: + type: layout_plugin.settings + base_entity_reference_field_settings: type: mapping mapping: diff --git a/core/core.api.php b/core/core.api.php index eb16ee098b68..ac0e385dae3c 100644 --- a/core/core.api.php +++ b/core/core.api.php @@ -2178,6 +2178,17 @@ function hook_display_variant_plugin_alter(array &$definitions) { $definitions['full_page']['admin_label'] = t('Block layout'); } +/** + * Allow modules to alter layout plugin definitions. + * + * @param \Drupal\Core\Layout\LayoutDefinition[] $definitions + * The array of layout definitions, keyed by plugin ID. + */ +function hook_layout_alter(&$definitions) { + // Remove a layout. + unset($definitions['twocol']); +} + /** * Flush all persistent and static caches. * diff --git a/core/lib/Drupal/Core/Layout/Annotation/Layout.php b/core/lib/Drupal/Core/Layout/Annotation/Layout.php new file mode 100644 index 000000000000..1cb5ff0a542c --- /dev/null +++ b/core/lib/Drupal/Core/Layout/Annotation/Layout.php @@ -0,0 +1,157 @@ +<?php + +namespace Drupal\Core\Layout\Annotation; + +use Drupal\Component\Annotation\Plugin; +use Drupal\Core\Layout\LayoutDefault; +use Drupal\Core\Layout\LayoutDefinition; + +/** + * Defines a Layout annotation object. + * + * Layouts are used to define a list of regions and then output render arrays + * in each of the regions, usually using a template. + * + * Plugin namespace: Plugin\Layout + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + * + * @see \Drupal\Core\Layout\LayoutInterface + * @see \Drupal\Core\Layout\LayoutDefault + * @see \Drupal\Core\Layout\LayoutPluginManager + * @see plugin_api + * + * @Annotation + */ +class Layout extends Plugin { + + /** + * The plugin ID. + * + * @var string + */ + public $id; + + /** + * The human-readable name. + * + * @var string + * + * @ingroup plugin_translatable + */ + public $label; + + /** + * An optional description for advanced layouts. + * + * Sometimes layouts are so complex that the name is insufficient to describe + * a layout such that a visually impaired administrator could layout a page + * for a non-visually impaired audience. If specified, it will provide a + * description that is used for accessibility purposes. + * + * @var string + * + * @ingroup plugin_translatable + */ + public $description; + + /** + * The human-readable category. + * + * @var string + * + * @see \Drupal\Component\Plugin\CategorizingPluginManagerInterface + * + * @ingroup plugin_translatable + */ + public $category; + + /** + * The template file to render this layout (relative to the 'path' given). + * + * If specified, then the layout_discovery module will register the template + * with hook_theme() and the module or theme registering this layout does not + * need to do it. + * + * @var string optional + * + * @see hook_theme() + */ + public $template; + + /** + * The theme hook used to render this layout. + * + * If specified, it's assumed that the module or theme registering this layout + * will also register the theme hook with hook_theme() itself. This is + * mutually exclusive with 'template' - you can't specify both. + * + * @var string optional + * + * @see hook_theme() + */ + public $theme_hook = 'layout'; + + /** + * Path (relative to the module or theme) to resources like icon or template. + * + * @var string optional + */ + public $path; + + /** + * The asset library. + * + * @var string optional + */ + public $library; + + /** + * The path to the preview image (relative to the 'path' given). + * + * @var string optional + */ + public $icon; + + /** + * An associative array of regions in this layout. + * + * The key of the array is the machine name of the region, and the value is + * an associative array with the following keys: + * - label: (string) The human-readable name of the region. + * + * Any remaining keys may have special meaning for the given layout plugin, + * but are undefined here. + * + * @var array + */ + public $regions = []; + + /** + * The default region. + * + * @var string + */ + public $default_region; + + /** + * The layout plugin class. + * + * This default value is used for plugins defined in layouts.yml that do not + * specify a class themselves. + * + * @var string + */ + public $class = LayoutDefault::class; + + /** + * {@inheritdoc} + */ + public function get() { + return new LayoutDefinition($this->definition); + } + +} diff --git a/core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php b/core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php new file mode 100644 index 000000000000..3d24b9dea4c1 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/DerivablePluginDefinitionInterface.php @@ -0,0 +1,42 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\Definition\PluginDefinitionInterface; + +/** + * Provides an interface for a derivable plugin definition. + * + * @see \Drupal\Component\Plugin\Derivative\DeriverInterface + * @see \Drupal\Core\Layout\ObjectDefinitionContainerDerivativeDiscoveryDecorator + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + * + * @todo Move into \Drupal\Component\Plugin\Definition in + * https://www.drupal.org/node/2821189. + */ +interface DerivablePluginDefinitionInterface extends PluginDefinitionInterface { + + /** + * Gets the name of the deriver of this plugin definition, if it exists. + * + * @return string|null + * Either the deriver class name, or NULL if the plugin is not derived. + */ + public function getDeriver(); + + /** + * Sets the deriver of this plugin definition. + * + * @param string|null $deriver + * Either the name of a class that implements + * \Drupal\Component\Plugin\Derivative\DeriverInterface, or NULL. + * + * @return $this + */ + public function setDeriver($deriver); + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutDefault.php b/core/lib/Drupal/Core/Layout/LayoutDefault.php new file mode 100644 index 000000000000..8f2370d93d99 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutDefault.php @@ -0,0 +1,84 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Utility\NestedArray; +use Drupal\Core\Plugin\PluginBase; + +/** + * Provides a default class for Layout plugins. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + */ +class LayoutDefault extends PluginBase implements LayoutInterface { + + /** + * The layout definition. + * + * @var \Drupal\Core\Layout\LayoutDefinition + */ + protected $pluginDefinition; + + /** + * {@inheritdoc} + */ + public function __construct(array $configuration, $plugin_id, $plugin_definition) { + parent::__construct($configuration, $plugin_id, $plugin_definition); + $this->setConfiguration($configuration); + } + + /** + * {@inheritdoc} + */ + public function build(array $regions) { + $build = array_intersect_key($regions, $this->pluginDefinition->getRegions()); + $build['#settings'] = $this->getConfiguration(); + $build['#layout'] = $this->pluginDefinition; + $build['#theme'] = $this->pluginDefinition->getThemeHook(); + if ($library = $this->pluginDefinition->getLibrary()) { + $build['#attached']['library'][] = $library; + } + return $build; + } + + /** + * {@inheritdoc} + */ + public function getConfiguration() { + return $this->configuration; + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = NestedArray::mergeDeep($this->defaultConfiguration(), $configuration); + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return []; + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + return []; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition + */ + public function getPluginDefinition() { + return parent::getPluginDefinition(); + } + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutDefinition.php b/core/lib/Drupal/Core/Layout/LayoutDefinition.php new file mode 100644 index 000000000000..9fab205cbe01 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutDefinition.php @@ -0,0 +1,552 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\Definition\PluginDefinitionInterface; + +/** + * Provides an implementation of a layout definition and its metadata. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + */ +class LayoutDefinition implements PluginDefinitionInterface, DerivablePluginDefinitionInterface { + + /** + * The plugin ID. + * + * @var string + */ + protected $id; + + /** + * The name of the provider of this layout definition. + * + * @todo Make protected after https://www.drupal.org/node/2818653. + * + * @var string + */ + public $provider; + + /** + * The name of the deriver of this layout definition, if any. + * + * @var string|null + */ + protected $deriver; + + /** + * The dependencies of this layout definition. + * + * @todo Make protected after https://www.drupal.org/node/2821191. + * + * @var array + */ + public $config_dependencies; + + /** + * The human-readable name. + * + * @var string + */ + protected $label; + + /** + * An optional description for advanced layouts. + * + * @var string + */ + protected $description; + + /** + * The human-readable category. + * + * @var string + */ + protected $category; + + /** + * The template file to render this layout (relative to the 'path' given). + * + * @var string|null + */ + protected $template; + + /** + * The path to the template. + * + * @var string + */ + protected $templatePath; + + /** + * The theme hook used to render this layout. + * + * @var string|null + */ + protected $theme_hook; + + /** + * Path (relative to the module or theme) to resources like icon or template. + * + * @var string + */ + protected $path; + + /** + * The asset library. + * + * @var string|null + */ + protected $library; + + /** + * The path to the preview image. + * + * @var string + */ + protected $icon; + + /** + * An associative array of regions in this layout. + * + * The key of the array is the machine name of the region, and the value is + * an associative array with the following keys: + * - label: (string) The human-readable name of the region. + * + * Any remaining keys may have special meaning for the given layout plugin, + * but are undefined here. + * + * @var array + */ + protected $regions = []; + + /** + * The default region. + * + * @var string + */ + protected $default_region; + + /** + * The name of the layout class. + * + * @var string + */ + protected $class; + + /** + * Any additional properties and values. + * + * @var array + */ + protected $additional = []; + + /** + * LayoutDefinition constructor. + * + * @param array $definition + * An array of values from the annotation. + */ + public function __construct(array $definition) { + foreach ($definition as $property => $value) { + $this->set($property, $value); + } + } + + /** + * Gets any arbitrary property. + * + * @param string $property + * The property to retrieve. + * + * @return mixed + * The value for that property, or NULL if the property does not exist. + */ + public function get($property) { + if (property_exists($this, $property)) { + $value = isset($this->{$property}) ? $this->{$property} : NULL; + } + else { + $value = isset($this->additional[$property]) ? $this->additional[$property] : NULL; + } + return $value; + } + + /** + * Sets a value to an arbitrary property. + * + * @param string $property + * The property to use for the value. + * @param mixed $value + * The value to set. + * + * @return $this + */ + public function set($property, $value) { + if (property_exists($this, $property)) { + $this->{$property} = $value; + } + else { + $this->additional[$property] = $value; + } + return $this; + } + + /** + * Gets the unique identifier of the layout definition. + * + * @return string + * The unique identifier of the layout definition. + */ + public function id() { + return $this->id; + } + + /** + * {@inheritdoc} + */ + public function getClass() { + return $this->class; + } + + /** + * {@inheritdoc} + */ + public function setClass($class) { + $this->class = $class; + return $this; + } + + /** + * Gets the human-readable name of the layout definition. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The human-readable name of the layout definition. + */ + public function getLabel() { + return $this->label; + } + + /** + * Sets the human-readable name of the layout definition. + * + * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $label + * The human-readable name of the layout definition. + * + * @return $this + */ + public function setLabel($label) { + $this->label = $label; + return $this; + } + + /** + * Gets the description of the layout definition. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The description of the layout definition. + */ + public function getDescription() { + return $this->description; + } + + /** + * Sets the description of the layout definition. + * + * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $description + * The description of the layout definition. + * + * @return $this + */ + public function setDescription($description) { + $this->description = $description; + return $this; + } + + /** + * Gets the human-readable category of the layout definition. + * + * @return string|\Drupal\Core\StringTranslation\TranslatableMarkup + * The human-readable category of the layout definition. + */ + public function getCategory() { + return $this->category; + } + + /** + * Sets the human-readable category of the layout definition. + * + * @param string|\Drupal\Core\StringTranslation\TranslatableMarkup $category + * The human-readable category of the layout definition. + * + * @return $this + */ + public function setCategory($category) { + $this->category = $category; + return $this; + } + + /** + * Gets the template name. + * + * @return string|null + * The template name, if it exists. + */ + public function getTemplate() { + return $this->template; + } + + /** + * Sets the template name. + * + * @param string|null $template + * The template name. + * + * @return $this + */ + public function setTemplate($template) { + $this->template = $template; + return $this; + } + + /** + * Gets the template path. + * + * @return string + * The template path. + */ + public function getTemplatePath() { + return $this->templatePath; + } + + /** + * Sets the template path. + * + * @param string $template_path + * The template path. + * + * @return $this + */ + public function setTemplatePath($template_path) { + $this->templatePath = $template_path; + return $this; + } + + /** + * Gets the theme hook. + * + * @return string|null + * The theme hook, if it exists. + */ + public function getThemeHook() { + return $this->theme_hook; + } + + /** + * Sets the theme hook. + * + * @param string $theme_hook + * The theme hook. + * + * @return $this + */ + public function setThemeHook($theme_hook) { + $this->theme_hook = $theme_hook; + return $this; + } + + /** + * Gets the base path for this layout definition. + * + * @return string + * The base path. + */ + public function getPath() { + return $this->path; + } + + /** + * Sets the base path for this layout definition. + * + * @param string $path + * The base path. + * + * @return $this + */ + public function setPath($path) { + $this->path = $path; + return $this; + } + + /** + * Gets the asset library for this layout definition. + * + * @return string|null + * The asset library, if it exists. + */ + public function getLibrary() { + return $this->library; + } + + /** + * Sets the asset library for this layout definition. + * + * @param string|null $library + * The asset library. + * + * @return $this + */ + public function setLibrary($library) { + $this->library = $library; + return $this; + } + + /** + * Gets the icon path for this layout definition. + * + * @return string|null + * The icon path, if it exists. + */ + public function getIconPath() { + return $this->icon; + } + + /** + * Sets the icon path for this layout definition. + * + * @param string|null $icon + * The icon path. + * + * @return $this + */ + public function setIconPath($icon) { + $this->icon = $icon; + return $this; + } + + /** + * Gets the regions for this layout definition. + * + * @return array[] + * The layout regions. The keys of the array are the machine names of the + * regions, and the values are an associative array with the following + * keys: + * - label: (string) The human-readable name of the region. + * Any remaining keys may have special meaning for the given layout plugin, + * but are undefined here. + */ + public function getRegions() { + return $this->regions; + } + + /** + * Sets the regions for this layout definition. + * + * @param array[] $regions + * An array of regions, see ::getRegions() for the format. + * + * @return $this + */ + public function setRegions(array $regions) { + $this->regions = $regions; + return $this; + } + + /** + * Gets the machine-readable region names. + * + * @return string[] + * An array of machine-readable region names. + */ + public function getRegionNames() { + return array_keys($this->getRegions()); + } + + /** + * Gets the human-readable region labels. + * + * @return string[] + * An array of human-readable region labels. + */ + public function getRegionLabels() { + $regions = $this->getRegions(); + return array_combine(array_keys($regions), array_column($regions, 'label')); + } + + /** + * Gets the default region. + * + * @return string + * The machine-readable name of the default region. + */ + public function getDefaultRegion() { + return $this->default_region; + } + + /** + * Sets the default region. + * + * @param string $default_region + * The machine-readable name of the default region. + * + * @return $this + */ + public function setDefaultRegion($default_region) { + $this->default_region = $default_region; + return $this; + } + + /** + * Gets the name of the provider of this layout definition. + * + * @return string + * The name of the provider of this layout definition. + */ + public function getProvider() { + return $this->provider; + } + + /** + * Gets the config dependencies of this layout definition. + * + * @return array + * An array of config dependencies. + * + * @see \Drupal\Core\Plugin\PluginDependencyTrait::calculatePluginDependencies() + */ + public function getConfigDependencies() { + return $this->config_dependencies; + } + + /** + * Sets the config dependencies of this layout definition. + * + * @param array $config_dependencies + * An array of config dependencies. + * + * @return $this + */ + public function setConfigDependencies(array $config_dependencies) { + $this->config_dependencies = $config_dependencies; + return $this; + } + + /** + * {@inheritdoc} + */ + public function getDeriver() { + return $this->deriver; + } + + /** + * {@inheritdoc} + */ + public function setDeriver($deriver) { + $this->deriver = $deriver; + return $this; + } + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutInterface.php b/core/lib/Drupal/Core/Layout/LayoutInterface.php new file mode 100644 index 000000000000..bb60df005db2 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutInterface.php @@ -0,0 +1,38 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\DerivativeInspectionInterface; +use Drupal\Component\Plugin\PluginInspectionInterface; +use Drupal\Component\Plugin\ConfigurablePluginInterface; + +/** + * Provides an interface for static Layout plugins. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + */ +interface LayoutInterface extends PluginInspectionInterface, DerivativeInspectionInterface, ConfigurablePluginInterface { + + /** + * Build a render array for layout with regions. + * + * @param array $regions + * An associative array keyed by region name, containing render arrays + * representing the content that should be placed in each region. + * + * @return array + * Render array for the layout with regions. + */ + public function build(array $regions); + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition + */ + public function getPluginDefinition(); + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutPluginManager.php b/core/lib/Drupal/Core/Layout/LayoutPluginManager.php new file mode 100644 index 000000000000..8ff11eb2bd24 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutPluginManager.php @@ -0,0 +1,225 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\ThemeHandlerInterface; +use Drupal\Core\Plugin\DefaultPluginManager; +use Drupal\Core\Plugin\Discovery\AnnotatedClassDiscovery; +use Drupal\Core\Plugin\Discovery\YamlDiscoveryDecorator; +use Drupal\Core\Layout\Annotation\Layout; + +/** + * Provides a plugin manager for layouts. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + */ +class LayoutPluginManager extends DefaultPluginManager implements LayoutPluginManagerInterface { + + /** + * The theme handler. + * + * @var \Drupal\Core\Extension\ThemeHandlerInterface + */ + protected $themeHandler; + + /** + * LayoutPluginManager constructor. + * + * @param \Traversable $namespaces + * An object that implements \Traversable which contains the root paths + * keyed by the corresponding namespace to look for plugin implementations. + * @param \Drupal\Core\Cache\CacheBackendInterface $cache_backend + * Cache backend instance to use. + * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler + * The module handler to invoke the alter hook with. + * @param \Drupal\Core\Extension\ThemeHandlerInterface $theme_handler + * The theme handler to invoke the alter hook with. + */ + public function __construct(\Traversable $namespaces, CacheBackendInterface $cache_backend, ModuleHandlerInterface $module_handler, ThemeHandlerInterface $theme_handler) { + parent::__construct('Plugin/Layout', $namespaces, $module_handler, LayoutInterface::class, Layout::class); + $this->themeHandler = $theme_handler; + + $this->setCacheBackend($cache_backend, 'layout'); + $this->alterInfo('layout'); + } + + /** + * {@inheritdoc} + */ + protected function providerExists($provider) { + return $this->moduleHandler->moduleExists($provider) || $this->themeHandler->themeExists($provider); + } + + /** + * {@inheritdoc} + */ + protected function getDiscovery() { + if (!$this->discovery) { + $discovery = new AnnotatedClassDiscovery($this->subdir, $this->namespaces, $this->pluginDefinitionAnnotationName, $this->additionalAnnotationNamespaces); + $discovery = new YamlDiscoveryDecorator($discovery, 'layouts', $this->moduleHandler->getModuleDirectories() + $this->themeHandler->getThemeDirectories()); + $discovery = new ObjectDefinitionDiscoveryDecorator($discovery, $this->pluginDefinitionAnnotationName); + $discovery = new ObjectDefinitionContainerDerivativeDiscoveryDecorator($discovery); + $this->discovery = $discovery; + } + return $this->discovery; + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + if (!$definition instanceof LayoutDefinition) { + throw new InvalidPluginDefinitionException($plugin_id, sprintf('The "%s" layout definition must extend %s', $plugin_id, LayoutDefinition::class)); + } + + // Keep class definitions standard with no leading slash. + // @todo Remove this once https://www.drupal.org/node/2824655 is resolved. + $definition->setClass(ltrim($definition->getClass(), '\\')); + + // Add the module or theme path to the 'path'. + $provider = $definition->getProvider(); + if ($this->moduleHandler->moduleExists($provider)) { + $base_path = $this->moduleHandler->getModule($provider)->getPath(); + } + elseif ($this->themeHandler->themeExists($provider)) { + $base_path = $this->themeHandler->getTheme($provider)->getPath(); + } + else { + $base_path = ''; + } + + $path = $definition->getPath(); + $path = !empty($path) ? $base_path . '/' . $path : $base_path; + $definition->setPath($path); + + // Add the base path to the icon path. + if ($icon_path = $definition->getIconPath()) { + $definition->setIconPath($path . '/' . $icon_path); + } + + // Add a dependency on the provider of the library. + if ($library = $definition->getLibrary()) { + $config_dependencies = $definition->getConfigDependencies(); + list($library_provider) = explode('/', $library, 2); + if ($this->moduleHandler->moduleExists($library_provider)) { + $config_dependencies['module'][] = $library_provider; + } + elseif ($this->themeHandler->themeExists($library_provider)) { + $config_dependencies['theme'][] = $library_provider; + } + $definition->setConfigDependencies($config_dependencies); + } + + // If 'template' is set, then we'll derive 'template_path' and 'theme_hook'. + $template = $definition->getTemplate(); + if (!empty($template)) { + $template_parts = explode('/', $template); + + $template = array_pop($template_parts); + $template_path = $path; + if (count($template_parts) > 0) { + $template_path .= '/' . implode('/', $template_parts); + } + $definition->setTemplate($template); + // Prepend 'layout__' so the base theme hook will be used. + // @todo Remove this workaround for https://www.drupal.org/node/2559825 in + // https://www.drupal.org/node/2834019. + $definition->setThemeHook('layout__' . strtr($template, '-', '_')); + $definition->setTemplatePath($template_path); + } + + if (!$definition->getDefaultRegion()) { + $definition->setDefaultRegion(key($definition->getRegions())); + } + } + + /** + * {@inheritdoc} + */ + public function getThemeImplementations() { + $hooks = []; + $hooks['layout'] = [ + 'render element' => 'content', + ]; + /** @var \Drupal\Core\Layout\LayoutDefinition[] $definitions */ + $definitions = $this->getDefinitions(); + foreach ($definitions as $definition) { + if ($template = $definition->getTemplate()) { + $hooks[$definition->getThemeHook()] = [ + 'render element' => 'content', + 'base hook' => 'layout', + 'template' => $template, + 'path' => $definition->getTemplatePath(), + ]; + } + } + return $hooks; + } + + /** + * {@inheritdoc} + */ + public function getCategories() { + // Fetch all categories from definitions and remove duplicates. + $categories = array_unique(array_values(array_map(function (LayoutDefinition $definition) { + return $definition->getCategory(); + }, $this->getDefinitions()))); + natcasesort($categories); + return $categories; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[] + */ + public function getSortedDefinitions(array $definitions = NULL, $label_key = 'label') { + // Sort the plugins first by category, then by label. + $definitions = isset($definitions) ? $definitions : $this->getDefinitions(); + // Suppress errors because PHPUnit will indirectly modify the contents, + // triggering https://bugs.php.net/bug.php?id=50688. + @uasort($definitions, function (LayoutDefinition $a, LayoutDefinition $b) { + if ($a->getCategory() != $b->getCategory()) { + return strnatcasecmp($a->getCategory(), $b->getCategory()); + } + return strnatcasecmp($a->getLabel(), $b->getLabel()); + }); + return $definitions; + } + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[][] + */ + public function getGroupedDefinitions(array $definitions = NULL, $label_key = 'label') { + $definitions = $this->getSortedDefinitions(isset($definitions) ? $definitions : $this->getDefinitions(), $label_key); + $grouped_definitions = []; + foreach ($definitions as $id => $definition) { + $grouped_definitions[(string) $definition->getCategory()][$id] = $definition; + } + return $grouped_definitions; + } + + /** + * {@inheritdoc} + */ + public function getLayoutOptions() { + $layout_options = []; + foreach ($this->getGroupedDefinitions() as $category => $layout_definitions) { + foreach ($layout_definitions as $name => $layout_definition) { + $layout_options[$category][$name] = $layout_definition->getLabel(); + } + } + return $layout_options; + } + +} diff --git a/core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php b/core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php new file mode 100644 index 000000000000..df15be03ad5e --- /dev/null +++ b/core/lib/Drupal/Core/Layout/LayoutPluginManagerInterface.php @@ -0,0 +1,70 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\CategorizingPluginManagerInterface; + +/** + * Provides the interface for a plugin manager of layouts. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + */ +interface LayoutPluginManagerInterface extends CategorizingPluginManagerInterface { + + /** + * Gets theme implementations for layouts. + * + * @return array + * An associative array of the same format as returned by hook_theme(). + * + * @see hook_theme() + */ + public function getThemeImplementations(); + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutInterface + */ + public function createInstance($plugin_id, array $configuration = []); + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition|null + */ + public function getDefinition($plugin_id, $exception_on_invalid = TRUE); + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[] + */ + public function getDefinitions(); + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[] + */ + public function getSortedDefinitions(array $definitions = NULL); + + /** + * {@inheritdoc} + * + * @return \Drupal\Core\Layout\LayoutDefinition[][] + */ + public function getGroupedDefinitions(array $definitions = NULL); + + /** + * Returns an array of layout labels grouped by category. + * + * @return string[][] + * A nested array of labels suitable for #options. + */ + public function getLayoutOptions(); + +} diff --git a/core/lib/Drupal/Core/Layout/ObjectDefinitionContainerDerivativeDiscoveryDecorator.php b/core/lib/Drupal/Core/Layout/ObjectDefinitionContainerDerivativeDiscoveryDecorator.php new file mode 100644 index 000000000000..02ecfb00925c --- /dev/null +++ b/core/lib/Drupal/Core/Layout/ObjectDefinitionContainerDerivativeDiscoveryDecorator.php @@ -0,0 +1,37 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\Exception\InvalidDeriverException; +use Drupal\Core\Plugin\Discovery\ContainerDerivativeDiscoveryDecorator; + +/** + * Allows object-based definitions to use derivatives. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + * + * @todo In https://www.drupal.org/node/2821189 merge into + * \Drupal\Component\Plugin\Discovery\DerivativeDiscoveryDecorator. + */ +class ObjectDefinitionContainerDerivativeDiscoveryDecorator extends ContainerDerivativeDiscoveryDecorator { + + /** + * {@inheritdoc} + */ + protected function getDeriverClass($base_definition) { + $class = NULL; + if ($base_definition instanceof DerivablePluginDefinitionInterface && $class = $base_definition->getDeriver()) { + if (!class_exists($class)) { + throw new InvalidDeriverException(sprintf('Plugin (%s) deriver "%s" does not exist.', $base_definition['id'], $class)); + } + if (!is_subclass_of($class, '\Drupal\Component\Plugin\Derivative\DeriverInterface')) { + throw new InvalidDeriverException(sprintf('Plugin (%s) deriver "%s" must implement \Drupal\Component\Plugin\Derivative\DeriverInterface.', $base_definition['id'], $class)); + } + } + return $class; + } + +} diff --git a/core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php b/core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php new file mode 100644 index 000000000000..e7a607135db7 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/ObjectDefinitionDiscoveryDecorator.php @@ -0,0 +1,81 @@ +<?php + +namespace Drupal\Core\Layout; + +use Drupal\Component\Plugin\Discovery\DiscoveryInterface; +use Drupal\Component\Plugin\Discovery\DiscoveryTrait; + +/** + * Ensures that all array-based definitions are converted to objects. + * + * @internal + * The layout system is currently experimental and should only be leveraged by + * experimental modules and development releases of contributed modules. + * See https://www.drupal.org/core/experimental for more information. + * + * @todo Move into \Drupal\Component\Plugin\Discovery in + * https://www.drupal.org/node/2822752. + */ +class ObjectDefinitionDiscoveryDecorator implements DiscoveryInterface { + + use DiscoveryTrait; + + /** + * The decorated plugin discovery. + * + * @var \Drupal\Component\Plugin\Discovery\DiscoveryInterface + */ + protected $decorated; + + /** + * The name of the annotation that contains the plugin definition. + * + * The class corresponding to this name must implement + * \Drupal\Component\Annotation\AnnotationInterface. + * + * @var string|null + */ + protected $pluginDefinitionAnnotationName; + + /** + * ObjectDefinitionDiscoveryDecorator constructor. + * + * @param \Drupal\Component\Plugin\Discovery\DiscoveryInterface $decorated + * The discovery object that is being decorated. + * @param string $plugin_definition_annotation_name + * The name of the annotation that contains the plugin definition. + */ + public function __construct(DiscoveryInterface $decorated, $plugin_definition_annotation_name) { + $this->decorated = $decorated; + $this->pluginDefinitionAnnotationName = $plugin_definition_annotation_name; + } + + /** + * {@inheritdoc} + */ + public function getDefinitions() { + $definitions = $this->decorated->getDefinitions(); + foreach ($definitions as $id => $definition) { + if (is_array($definition)) { + $definitions[$id] = (new $this->pluginDefinitionAnnotationName($definition))->get(); + } + } + return $definitions; + } + + /** + * Passes through all unknown calls onto the decorated object. + * + * @param string $method + * The method to call on the decorated plugin discovery. + * @param array $args + * The arguments to send to the method. + * + * @return mixed + * The method result. + */ + public function __call($method, $args) { + return call_user_func_array([$this->decorated, $method], $args); + } + +} diff --git a/core/modules/layout_discovery/layout_discovery.info.yml b/core/modules/layout_discovery/layout_discovery.info.yml new file mode 100644 index 000000000000..a9a4139ba641 --- /dev/null +++ b/core/modules/layout_discovery/layout_discovery.info.yml @@ -0,0 +1,6 @@ +name: 'Layout Discovery' +type: module +description: 'Provides a way for modules or themes to register layouts.' +package: Core (Experimental) +version: VERSION +core: 8.x diff --git a/core/modules/layout_discovery/layout_discovery.install b/core/modules/layout_discovery/layout_discovery.install new file mode 100644 index 000000000000..e6c99e089bcf --- /dev/null +++ b/core/modules/layout_discovery/layout_discovery.install @@ -0,0 +1,22 @@ +<?php + +/** + * @file + * Install, update, and uninstall functions for the Layout Discovery module. + */ + +/** + * Implements hook_requirements(). + */ +function layout_discovery_requirements($phase) { + $requirements = []; + if ($phase === 'install') { + if (\Drupal::moduleHandler()->moduleExists('layout_plugin')) { + $requirements['layout_discovery'] = [ + 'description' => t('Layout Discovery cannot be installed because the Layout Plugin module is installed and incompatible.'), + 'severity' => REQUIREMENT_ERROR, + ]; + } + } + return $requirements; +} diff --git a/core/modules/layout_discovery/layout_discovery.module b/core/modules/layout_discovery/layout_discovery.module new file mode 100644 index 000000000000..3cffee92cc60 --- /dev/null +++ b/core/modules/layout_discovery/layout_discovery.module @@ -0,0 +1,39 @@ +<?php + +/** + * @file + * Provides hook implementations for Layout Discovery. + */ + +/** + * Implements hook_help(). + */ +function layout_discovery_help($route_name) { + switch ($route_name) { + case 'help.page.layout_discovery': + $output = '<h3>' . t('About') . '</h3>'; + $output .= '<p>' . t('Layout Discovery allows modules or themes to register layouts, and for other modules to list the available layouts and render them.') . '</p>'; + $output .= '<p>' . t('For more information, see the <a href=":layout-discovery-documentation">online documentation for the Layout Discovery module</a>.', [':layout-discovery-documentation' => 'https://www.drupal.org/node/2619128']) . '</p>'; + return $output; + } +} + +/** + * Implements hook_theme(). + */ +function layout_discovery_theme() { + return \Drupal::service('plugin.manager.core.layout')->getThemeImplementations(); +} + +/** + * Prepares variables for layout templates. + * + * @param array &$variables + * An associative array containing: + * - content: An associative array containing the properties of the element. + * Properties used: #settings, #layout. + */ +function template_preprocess_layout(&$variables) { + $variables['settings'] = isset($variables['content']['#settings']) ? $variables['content']['#settings'] : []; + $variables['layout'] = isset($variables['content']['#layout']) ? $variables['content']['#layout'] : []; +} diff --git a/core/modules/layout_discovery/layout_discovery.services.yml b/core/modules/layout_discovery/layout_discovery.services.yml new file mode 100644 index 000000000000..1e24db4d6f2f --- /dev/null +++ b/core/modules/layout_discovery/layout_discovery.services.yml @@ -0,0 +1,4 @@ +services: + plugin.manager.core.layout: + class: Drupal\Core\Layout\LayoutPluginManager + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@theme_handler'] diff --git a/core/modules/layout_discovery/templates/layout.html.twig b/core/modules/layout_discovery/templates/layout.html.twig new file mode 100644 index 000000000000..88ea7457d4c5 --- /dev/null +++ b/core/modules/layout_discovery/templates/layout.html.twig @@ -0,0 +1,18 @@ +{# +/** + * @file + * Template for a generic layout. + */ +#} +{% set classes = [ + 'layout--' ~ layout.id|clean_class, +] %} +{% if content %} +<div{{ attributes.addClass(classes) }}> +{% for region in layout.getRegionNames %} + <div class="{{ 'region--' ~ region|clean_class }}"> + {{ content[region] }} + </div> +{% endfor %} +</div> +{% endif %} diff --git a/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php b/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php new file mode 100644 index 000000000000..860237738c0a --- /dev/null +++ b/core/modules/layout_discovery/tests/src/Kernel/LayoutTest.php @@ -0,0 +1,192 @@ +<?php + +namespace Drupal\Tests\layout_discovery\Kernel; + +use Drupal\Core\Form\FormState; +use Drupal\KernelTests\KernelTestBase; + +/** + * Tests Layout functionality. + * + * @group Layout + */ +class LayoutTest extends KernelTestBase { + + /** + * {@inheritdoc} + */ + public static $modules = ['system', 'layout_discovery', 'layout_test']; + + /** + * The layout plugin manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutPluginManager; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->layoutPluginManager = $this->container->get('plugin.manager.core.layout'); + } + + /** + * Test rendering a layout. + * + * @dataProvider renderLayoutData + */ + public function testRenderLayout($layout_id, $config, $regions, $html) { + $layout = $this->layoutPluginManager->createInstance($layout_id, $config); + $built['layout'] = $layout->build($regions); + + // Assume each layout is contained by a form, in order to ensure the + // building of the layout does not interfere with form processing. + $form_state = new FormState(); + $form_builder = $this->container->get('form_builder'); + $form_builder->prepareForm('the_form_id', $built, $form_state); + $form_builder->processForm('the_form_id', $built, $form_state); + + $this->render($built); + $this->assertRaw($html); + $this->assertRaw('<input data-drupal-selector="edit-the-form-id" type="hidden" name="form_id" value="the_form_id" />'); + } + + /** + * Data provider for testRenderLayout(). + */ + public function renderLayoutData() { + $data['layout_test_1col_with_form'] = [ + 'layout_test_1col', + [], + [ + 'top' => [ + '#process' => [[static::class, 'processCallback']], + ], + 'bottom' => [ + '#markup' => 'This is the bottom', + ], + ], + ]; + + $data['layout_test_1col'] = [ + 'layout_test_1col', + [], + [ + 'top' => [ + '#markup' => 'This is the top', + ], + 'bottom' => [ + '#markup' => 'This is the bottom', + ], + ], + ]; + + $data['layout_test_1col_no_template'] = [ + 'layout_test_1col_no_template', + [], + [ + 'top' => [ + '#markup' => 'This is the top', + ], + 'bottom' => [ + '#markup' => 'This is the bottom', + ], + ], + ]; + + $data['layout_test_2col'] = [ + 'layout_test_2col', + [], + [ + 'left' => [ + '#markup' => 'This is the left', + ], + 'right' => [ + '#markup' => 'This is the right', + ], + ], + ]; + + $data['layout_test_plugin'] = [ + 'layout_test_plugin', + [ + 'setting_1' => 'Config value', + ], + [ + 'main' => [ + '#markup' => 'Main region', + ], + ], + ]; + + $data['layout_test_1col_with_form'][] = <<<'EOD' +<div class="layout-example-1col clearfix"> + <div class="region-top"> + This string added by #process. + </div> + <div class="region-bottom"> + This is the bottom + </div> +</div> +EOD; + + $data['layout_test_1col'][] = <<<'EOD' +<div class="layout-example-1col clearfix"> + <div class="region-top"> + This is the top + </div> + <div class="region-bottom"> + This is the bottom + </div> +</div> +EOD; + + $data['layout_test_1col_no_template'][] = <<<'EOD' +<div data-drupal-selector="edit-layout" class="layout--layout-test-1col-no-template"> + <div class="region--top"> + This is the top + </div> + <div class="region--bottom"> + This is the bottom + </div> +</div> +EOD; + + $data['layout_test_2col'][] = <<<'EOD' +<div class="layout-example-2col clearfix"> + <div class="region-left"> + This is the left + </div> + <div class="region-right"> + This is the right + </div> +</div> +EOD; + + $data['layout_test_plugin'][] = <<<'EOD' +<div class="layout-test-plugin clearfix"> + <div> + <span class="setting-1-label">Blah: </span> + Config value + </div> + <div class="region-main"> + Main region + </div> +</div> +EOD; + + return $data; + } + + /** + * Provides a test #process callback. + */ + public static function processCallback($element) { + $element['#markup'] = 'This string added by #process.'; + return $element; + } + +} diff --git a/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml b/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml new file mode 100644 index 000000000000..521faafae117 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml @@ -0,0 +1,7 @@ +layout_plugin.settings.layout_test_plugin: + type: layout_plugin.settings + label: 'Layout test plugin settings' + mapping: + setting_1: + type: string + label: 'Setting 1' diff --git a/core/modules/system/tests/modules/layout_test/css/layout-test-2col.css b/core/modules/system/tests/modules/layout_test/css/layout-test-2col.css new file mode 100644 index 000000000000..d5c05b9eeb47 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/css/layout-test-2col.css @@ -0,0 +1,16 @@ + +.layout-example-2col .region-left { + float: left; + width: 50%; +} +* html .layout-example-2col .region-left { + width: 49.9%; +} + +.layout-example-2col .region-right { + float: left; + width: 50%; +} +* html .layout-example-2col .region-right { + width: 49.9%; +} diff --git a/core/modules/system/tests/modules/layout_test/layout_test.info.yml b/core/modules/system/tests/modules/layout_test/layout_test.info.yml new file mode 100644 index 000000000000..bfed2a5bb0ac --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.info.yml @@ -0,0 +1,6 @@ +name: 'Layout test' +type: module +description: 'Support module for testing layouts.' +package: Testing +version: VERSION +core: 8.x diff --git a/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml b/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml new file mode 100644 index 000000000000..f8fcf5b883f8 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml @@ -0,0 +1,29 @@ +layout_test_1col: + label: 1 column layout + category: Layout test + template: templates/layout-test-1col + regions: + top: + label: Top region + bottom: + label: Bottom region + +layout_test_2col: + label: 2 column layout + category: Layout test + template: templates/layout-test-2col + library: layout_test/layout_test_2col + regions: + left: + label: Left region + right: + label: Right region + +layout_test_1col_no_template: + label: 1 column layout (No template) + category: Layout test + regions: + top: + label: Top region + bottom: + label: Bottom region diff --git a/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml b/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml new file mode 100644 index 000000000000..a696c0fc6f71 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml @@ -0,0 +1,5 @@ +layout_test_2col: + version: 1.x + css: + theme: + css/layout-test-2col.css: {} diff --git a/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php b/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php new file mode 100644 index 000000000000..e678bfa6017d --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php @@ -0,0 +1,61 @@ +<?php + +namespace Drupal\layout_test\Plugin\Layout; + +use Drupal\Core\Form\FormStateInterface; +use Drupal\Core\Layout\LayoutDefault; +use Drupal\Core\Plugin\PluginFormInterface; + +/** + * The plugin that handles the default layout template. + * + * @Layout( + * id = "layout_test_plugin", + * label = @Translation("Layout plugin (with settings)"), + * category = @Translation("Layout test"), + * description = @Translation("Test layout"), + * template = "templates/layout-test-plugin", + * regions = { + * "main" = { + * "label" = @Translation("Main Region") + * } + * } + * ) + */ +class LayoutTestPlugin extends LayoutDefault implements PluginFormInterface { + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return [ + 'setting_1' => 'Default', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $form['setting_1'] = [ + '#type' => 'textfield', + '#title' => 'Blah', + '#default_value' => $this->configuration['setting_1'], + ]; + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration['setting_1'] = $form_state->getValue('setting_1'); + } + +} diff --git a/core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig b/core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig new file mode 100644 index 000000000000..e7a7eb5baa37 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/templates/layout-test-1col.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Template for an example 1 column layout. + */ +#} +<div class="layout-example-1col clearfix"> + <div class="region-top"> + {{ content.top }} + </div> + <div class="region-bottom"> + {{ content.bottom }} + </div> +</div> diff --git a/core/modules/system/tests/modules/layout_test/templates/layout-test-2col.html.twig b/core/modules/system/tests/modules/layout_test/templates/layout-test-2col.html.twig new file mode 100644 index 000000000000..11433ee99cb3 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/templates/layout-test-2col.html.twig @@ -0,0 +1,14 @@ +{# +/** + * @file + * Template for an example 2 column layout. + */ +#} +<div class="layout-example-2col clearfix"> + <div class="region-left"> + {{ content.left }} + </div> + <div class="region-right"> + {{ content.right }} + </div> +</div> diff --git a/core/modules/system/tests/modules/layout_test/templates/layout-test-plugin.html.twig b/core/modules/system/tests/modules/layout_test/templates/layout-test-plugin.html.twig new file mode 100644 index 000000000000..e49942c4cb3d --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/templates/layout-test-plugin.html.twig @@ -0,0 +1,15 @@ +{# +/** + * @file + * Template for layout_test_plugin layout. + */ +#} +<div class="layout-test-plugin clearfix"> + <div> + <span class="setting-1-label">Blah: </span> + {{ settings.setting_1 }} + </div> + <div class="region-main"> + {{ content.main }} + </div> +</div> diff --git a/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php b/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php new file mode 100644 index 000000000000..d892b47f1db0 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php @@ -0,0 +1,390 @@ +<?php + +namespace Drupal\Tests\Core\Layout; + +use Drupal\Component\Plugin\Derivative\DeriverBase; +use Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException; +use Drupal\Core\Cache\CacheBackendInterface; +use Drupal\Core\DependencyInjection\ContainerBuilder; +use Drupal\Core\Extension\Extension; +use Drupal\Core\Extension\ModuleHandlerInterface; +use Drupal\Core\Extension\ThemeHandlerInterface; +use Drupal\Core\Layout\LayoutDefault; +use Drupal\Core\Layout\LayoutDefinition; +use Drupal\Core\Layout\LayoutPluginManager; +use Drupal\Tests\UnitTestCase; +use org\bovigo\vfs\vfsStream; +use Prophecy\Argument; + +/** + * @coversDefaultClass \Drupal\Core\Layout\LayoutPluginManager + * @group Layout + */ +class LayoutPluginManagerTest extends UnitTestCase { + + /** + * The module handler. + * + * @var \Drupal\Core\Extension\ModuleHandlerInterface + */ + protected $moduleHandler; + + /** + * The theme handler. + * + * @var \Drupal\Core\Extension\ThemeHandlerInterface + */ + protected $themeHandler; + + /** + * Cache backend instance. + * + * @var \Drupal\Core\Cache\CacheBackendInterface + */ + protected $cacheBackend; + + /** + * The layout plugin manager. + * + * @var \Drupal\Core\Layout\LayoutPluginManagerInterface + */ + protected $layoutPluginManager; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->setUpFilesystem(); + + $container = new ContainerBuilder(); + $container->set('string_translation', $this->getStringTranslationStub()); + \Drupal::setContainer($container); + + $this->moduleHandler = $this->prophesize(ModuleHandlerInterface::class); + + $this->moduleHandler->moduleExists('module_a')->willReturn(TRUE); + $this->moduleHandler->moduleExists('theme_a')->willReturn(FALSE); + $this->moduleHandler->moduleExists('core')->willReturn(FALSE); + $this->moduleHandler->moduleExists('invalid_provider')->willReturn(FALSE); + + $module_a = new Extension('/', 'module', vfsStream::url('root/modules/module_a/module_a.layouts.yml')); + $this->moduleHandler->getModule('module_a')->willReturn($module_a); + $this->moduleHandler->getModuleDirectories()->willReturn(['module_a' => vfsStream::url('root/modules/module_a')]); + $this->moduleHandler->alter('layout', Argument::type('array'))->shouldBeCalled(); + + $this->themeHandler = $this->prophesize(ThemeHandlerInterface::class); + + $this->themeHandler->themeExists('theme_a')->willReturn(TRUE); + $this->themeHandler->themeExists('core')->willReturn(FALSE); + $this->themeHandler->themeExists('invalid_provider')->willReturn(FALSE); + + $theme_a = new Extension('/', 'theme', vfsStream::url('root/themes/theme_a/theme_a.layouts.yml')); + $this->themeHandler->getTheme('theme_a')->willReturn($theme_a); + $this->themeHandler->getThemeDirectories()->willReturn(['theme_a' => vfsStream::url('root/themes/theme_a')]); + + $this->cacheBackend = $this->prophesize(CacheBackendInterface::class); + + $namespaces = new \ArrayObject(['Drupal\Core' => vfsStream::url('root/core/lib/Drupal/Core')]); + $this->layoutPluginManager = new LayoutPluginManager($namespaces, $this->cacheBackend->reveal(), $this->moduleHandler->reveal(), $this->themeHandler->reveal(), $this->getStringTranslationStub()); + } + + /** + * @covers ::getDefinitions + * @covers ::providerExists + */ + public function testGetDefinitions() { + $expected = [ + 'module_a_provided_layout', + 'theme_a_provided_layout', + 'plugin_provided_layout', + ]; + + $layout_definitions = $this->layoutPluginManager->getDefinitions(); + $this->assertEquals($expected, array_keys($layout_definitions)); + $this->assertContainsOnlyInstancesOf(LayoutDefinition::class, $layout_definitions); + } + + /** + * @covers ::getDefinition + * @covers ::processDefinition + */ + public function testGetDefinition() { + $theme_a_path = vfsStream::url('root/themes/theme_a'); + $layout_definition = $this->layoutPluginManager->getDefinition('theme_a_provided_layout'); + $this->assertSame('theme_a_provided_layout', $layout_definition->id()); + $this->assertSame('2 column layout', $layout_definition->getLabel()); + $this->assertSame('Columns: 2', $layout_definition->getCategory()); + $this->assertSame('twocol', $layout_definition->getTemplate()); + $this->assertSame("$theme_a_path/templates", $layout_definition->getPath()); + $this->assertSame('theme_a/twocol', $layout_definition->getLibrary()); + $this->assertSame('layout__twocol', $layout_definition->getThemeHook()); + $this->assertSame("$theme_a_path/templates", $layout_definition->getTemplatePath()); + $this->assertSame('theme_a', $layout_definition->getProvider()); + $this->assertSame('right', $layout_definition->getDefaultRegion()); + $this->assertSame(LayoutDefault::class, $layout_definition->getClass()); + $expected_regions = [ + 'left' => [ + 'label' => 'Left region', + ], + 'right' => [ + 'label' => 'Right region', + ], + ]; + $this->assertSame($expected_regions, $layout_definition->getRegions()); + + $module_a_path = vfsStream::url('root/modules/module_a'); + $layout_definition = $this->layoutPluginManager->getDefinition('module_a_provided_layout'); + $this->assertSame('module_a_provided_layout', $layout_definition->id()); + $this->assertSame('1 column layout', $layout_definition->getLabel()); + $this->assertSame('Columns: 1', $layout_definition->getCategory()); + $this->assertSame(NULL, $layout_definition->getTemplate()); + $this->assertSame("$module_a_path/layouts", $layout_definition->getPath()); + $this->assertSame('module_a/onecol', $layout_definition->getLibrary()); + $this->assertSame('onecol', $layout_definition->getThemeHook()); + $this->assertSame(NULL, $layout_definition->getTemplatePath()); + $this->assertSame('module_a', $layout_definition->getProvider()); + $this->assertSame('top', $layout_definition->getDefaultRegion()); + $this->assertSame(LayoutDefault::class, $layout_definition->getClass()); + $expected_regions = [ + 'top' => [ + 'label' => 'Top region', + ], + 'bottom' => [ + 'label' => 'Bottom region', + ], + ]; + $this->assertSame($expected_regions, $layout_definition->getRegions()); + + $core_path = '/core/lib/Drupal/Core'; + $layout_definition = $this->layoutPluginManager->getDefinition('plugin_provided_layout'); + $this->assertSame('plugin_provided_layout', $layout_definition->id()); + $this->assertEquals('Layout plugin', $layout_definition->getLabel()); + $this->assertEquals('Columns: 1', $layout_definition->getCategory()); + $this->assertSame('plugin-provided-layout', $layout_definition->getTemplate()); + $this->assertSame($core_path, $layout_definition->getPath()); + $this->assertSame(NULL, $layout_definition->getLibrary()); + $this->assertSame('layout__plugin_provided_layout', $layout_definition->getThemeHook()); + $this->assertSame("$core_path/templates", $layout_definition->getTemplatePath()); + $this->assertSame('core', $layout_definition->getProvider()); + $this->assertSame('main', $layout_definition->getDefaultRegion()); + $this->assertSame('Drupal\Core\Plugin\Layout\TestLayout', $layout_definition->getClass()); + $expected_regions = [ + 'main' => [ + 'label' => 'Main Region', + ], + ]; + $this->assertEquals($expected_regions, $layout_definition->getRegions()); + } + + /** + * @covers ::processDefinition + */ + public function testProcessDefinition() { + $this->moduleHandler->alter('layout', Argument::type('array'))->shouldNotBeCalled(); + $this->setExpectedException(InvalidPluginDefinitionException::class, 'The "module_a_derived_layout:array_based" layout definition must extend ' . LayoutDefinition::class); + $module_a_provided_layout = <<<'EOS' +module_a_derived_layout: + deriver: \Drupal\Tests\Core\Layout\LayoutDeriver + array_based: true +EOS; + vfsStream::create([ + 'modules' => [ + 'module_a' => [ + 'module_a.layouts.yml' => $module_a_provided_layout, + ], + ], + ]); + $this->layoutPluginManager->getDefinitions(); + } + + /** + * @covers ::getThemeImplementations + */ + public function testGetThemeImplementations() { + $core_path = '/core/lib/Drupal/Core'; + $theme_a_path = vfsStream::url('root/themes/theme_a'); + $expected = [ + 'layout' => [ + 'render element' => 'content', + ], + 'layout__twocol' => [ + 'render element' => 'content', + 'base hook' => 'layout', + 'template' => 'twocol', + 'path' => "$theme_a_path/templates", + ], + 'layout__plugin_provided_layout' => [ + 'render element' => 'content', + 'base hook' => 'layout', + 'template' => 'plugin-provided-layout', + 'path' => "$core_path/templates", + ], + ]; + $theme_implementations = $this->layoutPluginManager->getThemeImplementations(); + $this->assertEquals($expected, $theme_implementations); + } + + /** + * @covers ::getCategories + */ + public function testGetCategories() { + $expected = [ + 'Columns: 1', + 'Columns: 2', + ]; + $categories = $this->layoutPluginManager->getCategories(); + $this->assertEquals($expected, $categories); + } + + /** + * @covers ::getSortedDefinitions + */ + public function testGetSortedDefinitions() { + $expected = [ + 'module_a_provided_layout', + 'plugin_provided_layout', + 'theme_a_provided_layout', + ]; + + $layout_definitions = $this->layoutPluginManager->getSortedDefinitions(); + $this->assertEquals($expected, array_keys($layout_definitions)); + $this->assertContainsOnlyInstancesOf(LayoutDefinition::class, $layout_definitions); + } + + /** + * @covers ::getGroupedDefinitions + */ + public function testGetGroupedDefinitions() { + $category_expected = [ + 'Columns: 1' => [ + 'module_a_provided_layout', + 'plugin_provided_layout', + ], + 'Columns: 2' => [ + 'theme_a_provided_layout', + ], + ]; + + $definitions = $this->layoutPluginManager->getGroupedDefinitions(); + $this->assertEquals(array_keys($category_expected), array_keys($definitions)); + foreach ($category_expected as $category => $expected) { + $this->assertArrayHasKey($category, $definitions); + $this->assertEquals($expected, array_keys($definitions[$category])); + $this->assertContainsOnlyInstancesOf(LayoutDefinition::class, $definitions[$category]); + } + } + + /** + * Sets up the filesystem with YAML files and annotated plugins. + */ + protected function setUpFilesystem() { + $module_a_provided_layout = <<<'EOS' +module_a_provided_layout: + label: 1 column layout + category: 'Columns: 1' + theme_hook: onecol + path: layouts + library: module_a/onecol + regions: + top: + label: Top region + bottom: + label: Bottom region +module_a_derived_layout: + deriver: \Drupal\Tests\Core\Layout\LayoutDeriver + invalid_provider: true +EOS; + $theme_a_provided_layout = <<<'EOS' +theme_a_provided_layout: + class: '\Drupal\Core\Layout\LayoutDefault' + label: 2 column layout + category: 'Columns: 2' + template: twocol + path: templates + library: theme_a/twocol + default_region: right + regions: + left: + label: Left region + right: + label: Right region +EOS; + $plugin_provided_layout = <<<'EOS' +<?php +namespace Drupal\Core\Plugin\Layout; +use Drupal\Core\Layout\LayoutDefault; +/** + * @Layout( + * id = "plugin_provided_layout", + * label = @Translation("Layout plugin"), + * category = @Translation("Columns: 1"), + * description = @Translation("Test layout"), + * path = "core/lib/Drupal/Core", + * template = "templates/plugin-provided-layout", + * regions = { + * "main" = { + * "label" = @Translation("Main Region") + * } + * } + * ) + */ +class TestLayout extends LayoutDefault {} +EOS; + vfsStream::setup('root'); + vfsStream::create([ + 'modules' => [ + 'module_a' => [ + 'module_a.layouts.yml' => $module_a_provided_layout, + ], + ], + ]); + vfsStream::create([ + 'themes' => [ + 'theme_a' => [ + 'theme_a.layouts.yml' => $theme_a_provided_layout, + ], + ], + ]); + vfsStream::create([ + 'core' => [ + 'lib' => [ + 'Drupal' => [ + 'Core' => [ + 'Plugin' => [ + 'Layout' => [ + 'TestLayout.php' => $plugin_provided_layout, + ], + ], + ], + ], + ], + ], + ]); + } + +} + +/** + * Provides a dynamic layout deriver for the test. + */ +class LayoutDeriver extends DeriverBase { + + /** + * {@inheritdoc} + */ + public function getDerivativeDefinitions($base_plugin_definition) { + if ($base_plugin_definition->get('array_based')) { + $this->derivatives['array_based'] = []; + } + if ($base_plugin_definition->get('invalid_provider')) { + $this->derivatives['invalid_provider'] = new LayoutDefinition([ + 'id' => 'invalid_provider', + 'provider' => 'invalid_provider', + ]); + } + return $this->derivatives; + } + +} -- GitLab