diff --git a/core/config/schema/core.layout.schema.yml b/core/config/schema/core.layout.schema.yml new file mode 100644 index 0000000..604c5b8 --- /dev/null +++ b/core/config/schema/core.layout.schema.yml @@ -0,0 +1,6 @@ +layout.settings: + type: mapping + mapping: { } + +layout.settings.*: + type: layout.settings diff --git a/core/core.services.yml b/core/core.services.yml index 6310c21..7d5f1e9 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -586,6 +586,9 @@ services: plugin.manager.block: class: Drupal\Core\Block\BlockManager parent: default_plugin_manager + plugin.manager.layout: + class: Drupal\Core\Layout\Plugin\LayoutPluginManager + arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@theme_handler'] plugin.manager.field.field_type: class: Drupal\Core\Field\FieldTypePluginManager arguments: ['@container.namespaces', '@cache.discovery', '@module_handler', '@typed_data_manager'] 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 0000000..2493935 --- /dev/null +++ b/core/lib/Drupal/Core/Layout/Annotation/Layout.php @@ -0,0 +1,136 @@ +pluginDefinition['label']; + } + + /** + * Gets the optional description for advanced layouts. + * + * @return string|NULL + * The layout description. + */ + public function getDescription() { + return isset($this->pluginDefinition['description']) ? $this->pluginDefinition['description'] : NULL; + } + + /** + * Gets the human-readable category. + * + * @return string + * The human-readable category. + */ + public function getCategory() { + return $this->pluginDefinition['category']; + } + + /** + * Gets human-readable list of regions keyed by machine name. + * + * @return string[] + * An array of human-readable region names keyed by machine name. + */ + public function getRegionNames() { + return $this->pluginDefinition['region_names']; + } + + /** + * Gets information on regions keyed by machine name. + * + * @return array + * An array of information on regions keyed by machine name. + */ + public function getRegionDefinitions() { + return $this->pluginDefinition['regions']; + } + + /** + * Gets the path to resources like icon or template. + * + * @return string|NULL + * The path relative to the Drupal root. + */ + public function getBasePath() { + return isset($this->pluginDefinition['path']) ? $this->pluginDefinition['path'] : NULL; + } + + /** + * Gets the path to the preview image. + * + * This can optionally be used in the user interface to show the layout of + * regions visually. + * + * @return string|NULL + * The path to preview image file. + */ + public function getIconFilename() { + return isset($this->pluginDefinition['icon']) ? $this->pluginDefinition['icon'] : NULL; + } + + /** + * Get the asset library. + * + * @return string|NULL + * The asset library. + */ + public function getLibrary() { + return isset($this->pluginDefinition['library']) ? $this->pluginDefinition['library'] : NULL; + } + + /** + * Gets the theme hook used to render this layout. + * + * @return string|NULL + * Theme hook. + */ + public function getThemeHook() { + return isset($this->pluginDefinition['theme']) ? $this->pluginDefinition['theme'] : NULL; + } + + /** + * {@inheritdoc} + */ + public function build(array $regions) { + $build['#content'] = $regions; + $build['#layout'] = $this->getPluginDefinition(); + $build['#settings'] = $this->getConfiguration(); + if ($theme = $this->getThemeHook()) { + $build['#theme'] = $theme; + } + if ($library = $this->getLibrary()) { + $build['#attached']['library'][] = $library; + } + return $build; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + return $form; + } + + /** + * {@inheritdoc} + */ + public function validateConfigurationForm(array &$form, FormStateInterface $form_state) { + } + + /** + * {@inheritdoc} + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + $this->configuration = $form_state->getValues(); + } + + /** + * {@inheritdoc} + */ + public function getConfiguration() { + return array_merge($this->defaultConfiguration(), $this->configuration); + } + + /** + * {@inheritdoc} + */ + public function setConfiguration(array $configuration) { + $this->configuration = $configuration; + } + + /** + * {@inheritdoc} + */ + public function defaultConfiguration() { + return []; + } + + /** + * {@inheritdoc} + */ + public function calculateDependencies() { + return isset($this->configuration['dependencies']) ? $this->configuration['dependencies'] : []; + } + +} diff --git a/core/lib/Drupal/Core/Layout/Plugin/LayoutDefault.php b/core/lib/Drupal/Core/Layout/Plugin/LayoutDefault.php new file mode 100644 index 0000000..355885b --- /dev/null +++ b/core/lib/Drupal/Core/Layout/Plugin/LayoutDefault.php @@ -0,0 +1,10 @@ +getDiscovery(); + $this->discovery = new YamlDiscoveryDecorator($discovery, 'layouts', $module_handler->getModuleDirectories() + $theme_handler->getThemeDirectories()); + $this->themeHandler = $theme_handler; + + $this->defaults += array( + 'type' => 'page', + // Used for plugins defined in layouts.yml that do not specify a class + // themselves. + 'class' => 'Drupal\Core\Layout\Plugin\LayoutDefault', + ); + + $this->setCacheBackend($cache_backend, 'layout'); + $this->alterInfo('layout'); + } + + /** + * {@inheritdoc} + */ + protected function providerExists($provider) { + return $this->moduleHandler->moduleExists($provider) || $this->themeHandler->themeExists($provider); + } + + /** + * {@inheritdoc} + */ + public function processDefinition(&$definition, $plugin_id) { + parent::processDefinition($definition, $plugin_id); + + // Add the module or theme path to the 'path'. + if ($this->moduleHandler->moduleExists($definition['provider'])) { + $definition['provider_type'] = 'module'; + $base_path = $this->moduleHandler->getModule($definition['provider'])->getPath(); + } + elseif ($this->themeHandler->themeExists($definition['provider'])) { + $definition['provider_type'] = 'theme'; + $base_path = $this->themeHandler->getTheme($definition['provider'])->getPath(); + } + else { + $base_path = ''; + } + $definition['path'] = !empty($definition['path']) ? $base_path . '/' . $definition['path'] : $base_path; + + // Add a dependency on the provider of the library. + if (!empty($definition['library'])) { + list ($library_provider, ) = explode('/', $definition['library']); + if ($this->moduleHandler->moduleExists($library_provider)) { + $definition['dependencies'] = ['module' => [$library_provider]]; + } + elseif ($this->themeHandler->themeExists($library_provider)) { + $definition['dependencies'] = ['theme' => [$library_provider]]; + } + } + + // Add the path to the icon filename. + if (!empty($definition['icon'])) { + $definition['icon'] = $definition['path'] . '/' . $definition['icon']; + } + + // If 'template' is set, then we'll derive 'template_path' and 'theme'. + if (!empty($definition['template'])) { + $template_parts = explode('/', $definition['template']); + + $definition['template'] = array_pop($template_parts); + $definition['theme'] = strtr($definition['template'], '-', '_'); + $definition['template_path'] = $definition['path']; + if (count($template_parts) > 0) { + $definition['template_path'] .= '/' . implode('/', $template_parts); + } + } + + // Generate the 'region_names' key from the 'regions' key. + $definition['region_names'] = array(); + if (!empty($definition['regions']) && is_array($definition['regions'])) { + foreach ($definition['regions'] as $region_id => $region_definition) { + $definition['region_names'][$region_id] = $region_definition['label']; + } + } + } + + /** + * {@inheritdoc} + */ + public function getLayoutOptions(array $params = []) { + $group_by_category = !empty($params['group_by_category']); + $plugins = $group_by_category ? $this->getGroupedDefinitions() : ['default' => $this->getDefinitions()]; + $categories = $group_by_category ? $this->getCategories() : ['default']; + + // Go through each category, sort it, and get just the labels out. + $options = array(); + foreach ($categories as $category) { + // Convert from a translation to a real string. + $category = (string) $category; + + // Sort the category. + $plugins[$category] = $this->getSortedDefinitions($plugins[$category]); + + // Put only the label in the options array. + foreach ($plugins[$category] as $id => $plugin) { + $options[$category][$id] = $plugin['label']; + } + } + + return $group_by_category ? $options : $options['default']; + } + + /** + * {@inheritdoc} + */ + public function getThemeImplementations() { + $plugins = $this->getDefinitions(); + + $theme_registry = []; + foreach ($plugins as $id => $definition) { + if (!empty($definition['template']) && !empty($definition['theme'])) { + $theme_registry[$definition['theme']] = [ + 'template' => $definition['template'], + 'path' => $definition['template_path'], + 'variables' => array( + 'content' => [], + 'settings' => [], + 'layout' => [], + ), + ]; + } + } + + return $theme_registry; + } + +} diff --git a/core/lib/Drupal/Core/Layout/Plugin/LayoutPluginManagerInterface.php b/core/lib/Drupal/Core/Layout/Plugin/LayoutPluginManagerInterface.php new file mode 100644 index 0000000..7ee292a --- /dev/null +++ b/core/lib/Drupal/Core/Layout/Plugin/LayoutPluginManagerInterface.php @@ -0,0 +1,39 @@ + 'entity-add-list', ), - )); + ), + // Theme functions automatically registered for layouts. + \Drupal::service('plugin.manager.layout')->getThemeImplementations()); } /** 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 0000000..32be5f8 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/config/schema/layout_test.schema.yml @@ -0,0 +1,7 @@ +layout.settings.layout_test_plugin: + type: layout.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-1col.css b/core/modules/system/tests/modules/layout_test/css/layout-test-1col.css new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/css/layout-test-1col.css @@ -0,0 +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 0000000..d5c05b9 --- /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 0000000..bfed2a5 --- /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 0000000..8372de2 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.layouts.yml @@ -0,0 +1,21 @@ +layout_test_1col: + label: 1 column layout + category: Layout test + template: templates/layout-test-1col + library: layout_test/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 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 0000000..f2bfb5a --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/layout_test.libraries.yml @@ -0,0 +1,11 @@ +layout_test_1col: + version: 1.x + css: + theme: + css/layout-test-1col.css: {} + +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 0000000..0cb0242 --- /dev/null +++ b/core/modules/system/tests/modules/layout_test/src/Plugin/Layout/LayoutTestPlugin.php @@ -0,0 +1,59 @@ + 'Default', + ]; + } + + /** + * {@inheritdoc} + */ + public function buildConfigurationForm(array $form, FormStateInterface $form_state) { + $configuration = $this->getConfiguration(); + $form['setting_1'] = [ + '#type' => 'textfield', + '#title' => 'Blah', + '#default_value' => $configuration['setting_1'], + ]; + return $form; + } + + /** + * @inheritDoc + */ + public function submitConfigurationForm(array &$form, FormStateInterface $form_state) { + parent::submitConfigurationForm($form, $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 0000000..e7a7eb5 --- /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. + */ +#} +
+
+ {{ content.top }} +
+
+ {{ content.bottom }} +
+
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 0000000..11433ee --- /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. + */ +#} +
+
+ {{ content.left }} +
+
+ {{ content.right }} +
+
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 0000000..e49942c --- /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. + */ +#} +
+
+ Blah: + {{ settings.setting_1 }} +
+
+ {{ content.main }} +
+
diff --git a/core/tests/Drupal/KernelTests/Core/Layout/LayoutTest.php b/core/tests/Drupal/KernelTests/Core/Layout/LayoutTest.php new file mode 100644 index 0000000..fc8ab15 --- /dev/null +++ b/core/tests/Drupal/KernelTests/Core/Layout/LayoutTest.php @@ -0,0 +1,141 @@ +layoutManager = $this->container->get('plugin.manager.layout'); + } + + /** + * Test listing the available layouts. + */ + public function testLayoutDefinitions() { + $expected_layouts = [ + 'layout_test_1col', + 'layout_test_2col', + 'layout_test_plugin', + ]; + $this->assertEquals($expected_layouts, array_keys($this->layoutManager->getDefinitions())); + } + + /** + * Test rendering a layout. + * + * @dataProvider renderLayoutData + */ + public function testRenderLayout($layout_id, $config, $regions, $html) { + /** @var \Drupal\Core\Layout\Plugin\LayoutInterface $layout */ + $layout = $this->layoutManager->createInstance($layout_id, $config); + $built = $layout->build($regions); + $this->render($built); + $this->assertRaw($html); + } + + /** + * Data provider for testRenderLayout(). + */ + public function renderLayoutData() { + $data = [ + 'layout_test_1col' => [ + 'layout_test_1col', + [], + [ + 'top' => [ + '#markup' => 'This is the top', + ], + 'bottom' => [ + '#markup' => 'This is the bottom', + ], + ], + ], + + 'layout_test_2col' => [ + 'layout_test_2col', + [], + [ + 'left' => [ + '#markup' => 'This is the left', + ], + 'right' => [ + '#markup' => 'This is the right', + ], + ], + ], + + 'layout_test_plugin' => [ + 'layout_test_plugin', + [ + 'setting_1' => 'Config value' + ], + [ + 'main' => [ + '#markup' => 'Main region', + ], + ] + ], + ]; + + $data['layout_test_1col'][] = <<<'EOD' +
+
+ This is the top +
+
+ This is the bottom +
+
+EOD; + + $data['layout_test_2col'][] = <<<'EOD' +
+
+ This is the left +
+
+ This is the right +
+
+EOD; + + $data['layout_test_plugin'][] = <<<'EOD' +
+
+ Blah: + Config value +
+
+ Main region +
+
+EOD; + + return $data; + } + +} 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 0000000..2dea742 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Layout/LayoutPluginManagerTest.php @@ -0,0 +1,182 @@ +root . '/modules/layout_plugin_test/src'; + + $cache_backend = $this->getMock('Drupal\Core\Cache\CacheBackendInterface'); + + $module_handler = $this->getMock('Drupal\Core\Extension\ModuleHandlerInterface'); + $module_handler->method('getModuleDirectories')->willReturn(array()); + $module_handler->method('moduleExists')->willReturn(TRUE); + $extension = $this->getMockBuilder('Drupal\Core\Extension\Extension') + ->disableOriginalConstructor() + ->getMock(); + $extension->method('getPath')->willReturn('modules/layout_plugin_test'); + $module_handler->method('getModule')->willReturn($extension); + + $theme_handler = $this->getMock('Drupal\Core\Extension\ThemeHandlerInterface'); + $theme_handler->method('getThemeDirectories')->willReturn(array()); + + $plugin_manager = new LayoutPluginManager($namespaces, $cache_backend, $module_handler, $theme_handler); + + // A simple definition with only the required keys. + $definition = [ + 'label' => 'Simple layout', + 'category' => 'Test layouts', + 'theme' => 'simple_layout', + 'provider' => 'layout_plugin_test', + 'regions' => [ + 'first' => ['label' => 'First region'], + 'second' => ['label' => 'Second region'], + ], + ]; + $plugin_manager->processDefinition($definition, 'simple_layout'); + $this->assertEquals('modules/layout_plugin_test', $definition['path']); + $this->assertEquals([ + 'first' => 'First region', + 'second' => 'Second region' + ], $definition['region_names']); + + // A more complex definition. + $definition = [ + 'label' => 'Complex layout', + 'category' => 'Test layouts', + 'template' => 'complex-layout', + 'library' => 'library_module/library_name', + 'provider' => 'layout_plugin_test', + 'path' => 'layout/complex', + 'icon' => 'complex-layout.png', + 'regions' => [ + 'first' => ['label' => 'First region'], + 'second' => ['label' => 'Second region'], + ], + ]; + $plugin_manager->processDefinition($definition, 'complex_layout'); + $this->assertEquals('modules/layout_plugin_test/layout/complex', $definition['path']); + $this->assertEquals('modules/layout_plugin_test/layout/complex', $definition['template_path']); + $this->assertEquals('modules/layout_plugin_test/layout/complex/complex-layout.png', $definition['icon']); + $this->assertEquals('complex_layout', $definition['theme']); + $this->assertEquals(['module' => ['library_module']], $definition['dependencies']); + + // A layout with a template path. + $definition = [ + 'label' => 'Split layout', + 'category' => 'Test layouts', + 'template' => 'templates/split-layout', + 'provider' => 'layout_plugin_test', + 'path' => 'layouts', + 'icon' => 'images/split-layout.png', + 'regions' => [ + 'first' => ['label' => 'First region'], + 'second' => ['label' => 'Second region'], + ], + ]; + $plugin_manager->processDefinition($definition, 'split_layout'); + $this->assertEquals('modules/layout_plugin_test/layouts', $definition['path']); + $this->assertEquals('modules/layout_plugin_test/layouts/templates', $definition['template_path']); + $this->assertEquals('modules/layout_plugin_test/layouts/images/split-layout.png', $definition['icon']); + $this->assertEquals('split_layout', $definition['theme']); + } + + /** + * Test getting layout options. + * + * @covers ::getLayoutOptions + */ + public function testGetLayoutOptions() { + /** @var LayoutPluginManager|\PHPUnit_Framework_MockObject_MockBuilder $layout_manager */ + $layout_manager = $this->getMockBuilder(LayoutPluginManager::class) + ->disableOriginalConstructor() + ->setMethods(['getDefinitions']) + ->getMock(); + + $layout_manager->method('getDefinitions') + ->willReturn([ + 'simple_layout' => [ + 'label' => 'Simple layout', + 'category' => 'Test layouts', + ], + 'complex_layout' => [ + 'label' => 'Complex layout', + 'category' => 'Test layouts', + ], + ]); + + $options = $layout_manager->getLayoutOptions(); + $this->assertEquals([ + 'simple_layout' => 'Simple layout', + 'complex_layout' => 'Complex layout', + ], $options); + + $options = $layout_manager->getLayoutOptions(array('group_by_category' => TRUE)); + $this->assertEquals([ + 'Test layouts' => [ + 'simple_layout' => 'Simple layout', + 'complex_layout' => 'Complex layout', + ], + ], $options); + } + + /** + * Tests layout theme implementations. + * + * @covers ::getThemeImplementations + */ + public function testGetThemeImplementations() { + /** @var LayoutPluginManager|\PHPUnit_Framework_MockObject_MockBuilder $layout_manager */ + $layout_manager = $this->getMockBuilder(LayoutPluginManager::class) + ->disableOriginalConstructor() + ->setMethods(['getDefinitions']) + ->getMock(); + + $layout_manager->method('getDefinitions') + ->willReturn([ + // Should get template registered automatically. + 'simple_layout' => [ + 'path' => 'modules/layout_plugin_test', + 'template_path' => 'modules/layout_plugin_test/templates', + 'template' => 'simple-layout', + 'theme' => 'simple_layout', + ], + // Shouldn't get registered automatically. + 'complex_layout' => [ + 'path' => 'modules/layout_plugin_test', + 'theme' => 'complex_layout', + ], + ]); + + $theme_registry = $layout_manager->getThemeImplementations(); + $this->assertEquals([ + 'simple_layout' => [ + 'template' => 'simple-layout', + 'path' => 'modules/layout_plugin_test/templates', + 'variables' => [ + 'content' => [], + 'settings' => [], + 'layout' => [], + ], + ], + ], $theme_registry); + } + +}