diff --git a/core/lib/Drupal/Core/Extension/ThemeHandler.php b/core/lib/Drupal/Core/Extension/ThemeHandler.php index d54ff1fa1a..b478c1a26a 100644 --- a/core/lib/Drupal/Core/Extension/ThemeHandler.php +++ b/core/lib/Drupal/Core/Extension/ThemeHandler.php @@ -278,6 +278,7 @@ public function rebuildThemeData() { 'screenshot' => 'screenshot.png', 'php' => DRUPAL_MINIMUM_PHP, 'libraries' => [], + 'dependencies' => [] ]; $sub_themes = []; @@ -364,6 +365,17 @@ public function rebuildThemeData() { } } + foreach ($themes as $key => $theme) { + // buildModuleDependencies() adds a theme->requires array that contains + // both module and base theme dependencies, if they are specified. Ensure + // that every theme stores the list of module dependencies separately + // from the full requires list. + if (!isset($theme->requires)) { + $theme->requires = []; + } + $themes[$key]->module_dependencies = isset($theme->base_themes) ? array_diff_key($theme->requires, $theme->base_themes) : $theme->requires; + } + return $themes; } diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php index 43a5469e3f..56dff35f27 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\ModuleInstallerInterface; use Drupal\Core\Routing\RouteBuilderInterface; use Drupal\Core\State\StateInterface; use Psr\Log\LoggerInterface; @@ -61,6 +62,11 @@ class ThemeInstaller implements ThemeInstallerInterface { */ protected $logger; + /** + * The module installer used to install modules depended on by themes. + */ + protected $moduleInstaller; + /** * Constructs a new ThemeInstaller. * @@ -85,8 +91,10 @@ class ThemeInstaller implements ThemeInstallerInterface { * A logger instance. * @param \Drupal\Core\State\StateInterface $state * The state store. + * @param \Drupal\Core\Extension\ModuleInstallerInterface $module_installer + * The module installer. */ - public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state) { + public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, ConfigInstallerInterface $config_installer, ModuleHandlerInterface $module_handler, ConfigManagerInterface $config_manager, AssetCollectionOptimizerInterface $css_collection_optimizer, RouteBuilderInterface $route_builder, LoggerInterface $logger, StateInterface $state, ModuleInstallerInterface $module_installer = NULL) { $this->themeHandler = $theme_handler; $this->configFactory = $config_factory; $this->configInstaller = $config_installer; @@ -96,6 +104,7 @@ public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryI $this->routeBuilder = $route_builder; $this->logger = $logger; $this->state = $state; + $this->moduleInstaller = $module_installer ?: \Drupal::service('module_installer'); } /** @@ -122,9 +131,15 @@ public function install(array $theme_list, $install_dependencies = TRUE) { } foreach ($theme_list as $theme => $value) { - // Add dependencies to the list. The new themes will be processed as - // the parent foreach loop continues. - foreach (array_keys($theme_data[$theme]->requires) as $dependency) { + $module_dependencies = $theme_data[$theme]->module_dependencies; + $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $module_dependencies); + + // Install the module dependencies. + $this->moduleInstaller->install(array_keys($module_dependencies)); + + // Add dependencies to the list of themes to install. The new themes + // will be processed as the parent foreach loop continues. + foreach (array_keys($theme_dependencies) as $dependency) { if (!isset($theme_data[$dependency])) { // The dependency does not exist. return FALSE; diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php index 4af1dcb856..9a6327edb6 100644 --- a/core/modules/system/src/Controller/SystemController.php +++ b/core/modules/system/src/Controller/SystemController.php @@ -10,6 +10,7 @@ use Drupal\Core\Menu\MenuTreeParameters; use Drupal\Core\Theme\ThemeAccessCheck; use Drupal\Core\Url; +use Drupal\system\Form\ModulesListForm; use Drupal\system\SystemManager; use Symfony\Component\DependencyInjection\ContainerInterface; @@ -190,6 +191,7 @@ public function themesPage() { $theme_groups = ['installed' => [], 'uninstalled' => []]; $admin_theme = $config->get('admin'); $admin_theme_options = []; + $modules = []; foreach ($themes as &$theme) { if (!empty($theme->info['hidden'])) { @@ -232,9 +234,37 @@ public function themesPage() { $theme->incompatible_base = (isset($theme->info['base theme']) && !($theme->base_themes === array_filter($theme->base_themes))); // Confirm that the theme engine is available. $theme->incompatible_engine = isset($theme->info['engine']) && !isset($theme->owner); + // Confirm that module dependencies are available. + $theme->incompatible_module = FALSE; } + + // Check module dependencies. + if ($theme->module_dependencies) { + if (empty($modules)) { + $modules = system_rebuild_module_data(); + } + foreach ($theme->module_dependencies as $dependency => $version) { + if ($incompatible = ModulesListForm::checkDependencyMessage($modules, $dependency, $version)) { + $theme->module_dependencies[$dependency] = $incompatible; + $theme->incompatible_module = TRUE; + } + // Only display visible modules. + elseif (!empty($modules[$dependency]->hidden)) { + unset($theme->module_dependencies[$dependency]); + } else { + $name = $modules[$dependency]->info['name']; + if ($modules[$dependency]->status) { + $theme->module_dependencies[$dependency] = $this->t('@module', array('@module' => $name)); + } + else { + $theme->module_dependencies[$dependency] = $this->t('@module (disabled)', array('@module' => $name)); + } + } + } + } + $theme->operations = []; - if (!empty($theme->status) || !$theme->incompatible_core && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) { + if (!empty($theme->status) || !$theme->incompatible_core && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine && !$theme->incompatible_module) { // Create the operations links. $query['theme'] = $theme->getName(); if ($this->themeAccess->checkAccess($theme->getName())) { diff --git a/core/modules/system/src/Controller/ThemeController.php b/core/modules/system/src/Controller/ThemeController.php index 9160085bee..2261e6e851 100644 --- a/core/modules/system/src/Controller/ThemeController.php +++ b/core/modules/system/src/Controller/ThemeController.php @@ -7,6 +7,7 @@ use Drupal\Core\Config\UnmetDependenciesException; use Drupal\Core\Controller\ControllerBase; use Drupal\Core\Extension\ThemeHandlerInterface; +use Drupal\Core\Messenger\MessengerInterface; use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; @@ -23,6 +24,13 @@ class ThemeController extends ControllerBase { */ protected $themeHandler; + /** + * The messenger. + * + * @var \Drupal\Core\Messenger\MessengerInterface + */ + protected $messenger; + /** * Constructs a new ThemeController. * @@ -30,10 +38,13 @@ class ThemeController extends ControllerBase { * The theme handler. * @param \Drupal\Core\Config\ConfigFactoryInterface $config_factory * The config factory. + * @param \Drupal\Core\Messenger\MessengerInterface $messenger + * The messenger. */ - public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory) { + public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryInterface $config_factory, MessengerInterface $messenger) { $this->themeHandler = $theme_handler; $this->configFactory = $config_factory; + $this->messenger = $messenger; } /** @@ -42,7 +53,8 @@ public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryI public static function create(ContainerInterface $container) { return new static( $container->get('theme_handler'), - $container->get('config.factory') + $container->get('config.factory'), + $container->get('messenger') ); } @@ -106,9 +118,24 @@ public function install(Request $request) { if (isset($theme)) { try { + $previously_installed_modules = \Drupal::moduleHandler()->getModuleList(); if ($this->themeHandler->install([$theme])) { - $themes = $this->themeHandler->listInfo(); - drupal_set_message($this->t('The %theme theme has been installed.', ['%theme' => $themes[$theme]->info['name']])); + $theme_data = $this->themeHandler->listInfo(); + if ($theme_data[$theme]->module_dependencies) { + $module_data = system_rebuild_module_data(); + $newly_installed_modules = array_diff_key($theme_data[$theme]->module_dependencies, $previously_installed_modules); + $newly_installed_modules_names = array_map(function ($module_name) use ($module_data) { + return $module_data[$module_name]->info['name']; + }, array_keys($newly_installed_modules)); + $this->messenger->addStatus($this->formatPlural(count($newly_installed_modules_names), 'The %theme theme and its module dependency, %name, have been installed.', 'The %theme theme and its @count module dependencies have been installed: %names.', [ + '%theme' => $theme_data[$theme]->info['name'], + '%name' => $newly_installed_modules_names[0], + '%names' => implode(', ', $newly_installed_modules_names), + ])); + } + else { + drupal_set_message($this->t('The %theme theme has been installed.', ['%theme' => $theme_data[$theme]->info['name']])); + } } else { drupal_set_message($this->t('The %theme theme was not found.', ['%theme' => $theme]), 'error'); diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php index 3ff6c3d09d..960a7e2ab7 100644 --- a/core/modules/system/src/Form/ModulesListForm.php +++ b/core/modules/system/src/Form/ModulesListForm.php @@ -190,11 +190,48 @@ public function buildForm(array $form, FormStateInterface $form_state) { return $form; } + /** + * Determines a message for missing/invalid theme/module dependencies. + * + * @param array $modules + * The list of existing modules. + * @param string $dependency + * The module dependency to check. + * @param array $version + * Version requirement data from the module or theme declaring the + * dependency we are checking. + * + * @return string|null + * NULL if compatible, otherwise a string describing the incompatibility. + */ + public static function checkDependencyMessage(array $modules, $dependency, $version) { + if (!isset($modules[$dependency])) { + return t('@module (missing)', ['@module' => Unicode::ucfirst($dependency)]); + } + else { + $name = $modules[$dependency]->info['name']; + // Check if it is incompatible with the dependency's version. + if ($incompatible_version = drupal_check_incompatibility($version, str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $modules[$dependency]->info['version']))) { + return t('@module (incompatible with version @version)', [ + '@module' => $name . $incompatible_version, + '@version' => $modules[$dependency]->info['version'], + ]); + } + // Disable the checkbox if the dependency is incompatible with this + // version of Drupal core. + elseif ($modules[$dependency]->info['core'] != \Drupal::CORE_COMPATIBILITY) { + return t('@module (incompatible with this version of Drupal core)', [ + '@module' => $name, + ]); + } + } + } + /** * Builds a table row for the system modules page. * * @param array $modules - * The list existing modules. + * The list of existing modules. * @param \Drupal\Core\Extension\Extension $module * The module for which to build the form row. * @param $distribution @@ -301,35 +338,20 @@ protected function buildRow(array $modules, Extension $module, $distribution) { // If this module requires other modules, add them to the array. foreach ($module->requires as $dependency => $version) { - if (!isset($modules[$dependency])) { - $row['#requires'][$dependency] = $this->t('@module (missing)', ['@module' => Unicode::ucfirst($dependency)]); - $row['enable']['#disabled'] = TRUE; - } - // Only display visible modules. - elseif (empty($modules[$dependency]->hidden)) { - $name = $modules[$dependency]->info['name']; - // Disable the module's checkbox if it is incompatible with the - // dependency's version. - if ($incompatible_version = drupal_check_incompatibility($version, str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $modules[$dependency]->info['version']))) { - $row['#requires'][$dependency] = $this->t('@module (incompatible with version @version)', [ - '@module' => $name . $incompatible_version, - '@version' => $modules[$dependency]->info['version'], - ]); - $row['enable']['#disabled'] = TRUE; - } - // Disable the checkbox if the dependency is incompatible with this - // version of Drupal core. - elseif ($modules[$dependency]->info['core'] != \Drupal::CORE_COMPATIBILITY) { - $row['#requires'][$dependency] = $this->t('@module (incompatible with this version of Drupal core)', [ - '@module' => $name, - ]); + // Only display missing or visible modules. + if (empty($modules[$dependency]->hidden)) { + if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $version)) { + $row['#requires'][$dependency] = $incompatible; $row['enable']['#disabled'] = TRUE; } - elseif ($modules[$dependency]->status) { - $row['#requires'][$dependency] = $this->t('@module', ['@module' => $name]); - } else { - $row['#requires'][$dependency] = $this->t('@module (disabled)', ['@module' => $name]); + $name = $modules[$dependency]->info['name']; + if ($modules[$dependency]->status) { + $row['#requires'][$dependency] = $this->t('@module', ['@module' => $name]); + } + else { + $row['#requires'][$dependency] = $this->t('@module (disabled)', ['@module' => $name]); + } } } } diff --git a/core/modules/system/system.admin.inc b/core/modules/system/system.admin.inc index 799869bbc3..13bbd07fa7 100644 --- a/core/modules/system/system.admin.inc +++ b/core/modules/system/system.admin.inc @@ -165,6 +165,8 @@ function template_preprocess_system_modules_details(&$variables) { ]; $module['requires'] = $renderer->render($requires); } + // @TODO: Add theme dependencies, see + // https://www.drupal.org/project/drupal/issues/2937952 if (!empty($module['#required_by'])) { $required_by = [ '#theme' => 'item_list', @@ -290,6 +292,15 @@ function template_preprocess_system_themes_page(&$variables) { $current_theme['is_default'] = $theme->is_default; $current_theme['is_admin'] = $theme->is_admin; + $current_theme['module_dependencies'] = []; + if (!empty($theme->module_dependencies)) { + $current_theme['module_dependencies'] = [ + '#theme' => 'item_list', + '#items' => $theme->module_dependencies, + '#context' => ['list_style' => 'comma-list'], + ]; + } + // Make sure to provide feedback on compatibility. $current_theme['incompatible'] = ''; if (!empty($theme->incompatible_core)) { @@ -310,6 +321,9 @@ function template_preprocess_system_themes_page(&$variables) { elseif (!empty($theme->incompatible_engine)) { $current_theme['incompatible'] = t('This theme requires the theme engine @theme_engine to operate correctly.', ['@theme_engine' => $theme->info['engine']]); } + elseif (!empty($theme->incompatible_module)) { + $current_theme['incompatible'] = t('This theme requires the listed modules to operate correctly.'); + } // Build operation links. $current_theme['operations'] = [ diff --git a/core/modules/system/templates/system-themes-page.html.twig b/core/modules/system/templates/system-themes-page.html.twig index 6e65d7641b..6916151f22 100644 --- a/core/modules/system/templates/system-themes-page.html.twig +++ b/core/modules/system/templates/system-themes-page.html.twig @@ -22,6 +22,7 @@ * - notes: Identifies what context this theme is being used in, e.g., * default theme, admin theme. * - incompatible: Text describing any compatibility issues. + * - module_dependencies: A list of modules that this theme requires. * - operations: A list of operation links, e.g., Settings, Enable, Disable, * etc. these links should only be displayed if the theme is compatible. * @@ -62,6 +63,9 @@ {%- endif -%}
{{ theme.description }}
+ {% if theme.module_dependencies %} +
Requires: {{ theme.module_dependencies }}
+ {% endif %} {# Display operation links if the theme is compatible. #} {% if theme.incompatible %}
{{ theme.incompatible }}
diff --git a/core/tests/Drupal/KernelTests/Core/Asset/LibraryDiscoveryIntegrationTest.php b/core/tests/Drupal/KernelTests/Core/Asset/LibraryDiscoveryIntegrationTest.php index a77adfcdc1..29792d1388 100644 --- a/core/tests/Drupal/KernelTests/Core/Asset/LibraryDiscoveryIntegrationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Asset/LibraryDiscoveryIntegrationTest.php @@ -13,6 +13,11 @@ */ class LibraryDiscoveryIntegrationTest extends KernelTestBase { + /** + * {@inheritdoc} + */ + public static $modules = ['system']; + /** * The library discovery service. * diff --git a/core/tests/Drupal/KernelTests/Core/Render/ElementInfoIntegrationTest.php b/core/tests/Drupal/KernelTests/Core/Render/ElementInfoIntegrationTest.php index cd33fcb6dc..512a3bd40a 100644 --- a/core/tests/Drupal/KernelTests/Core/Render/ElementInfoIntegrationTest.php +++ b/core/tests/Drupal/KernelTests/Core/Render/ElementInfoIntegrationTest.php @@ -11,6 +11,11 @@ */ class ElementInfoIntegrationTest extends KernelTestBase { + /** + * {@inheritdoc} + */ + public static $modules = ['system']; + /** * {@inheritdoc} */ diff --git a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php index 61f647528d..aa2bcb87da 100644 --- a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php +++ b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php @@ -5,6 +5,7 @@ use Drupal\Core\DependencyInjection\ContainerBuilder; use Drupal\Core\Extension\ExtensionNameLengthException; use Drupal\KernelTests\KernelTestBase; +use Drupal\test_theme_dependency\Service; /** * Tests installing and uninstalling of themes. @@ -350,6 +351,15 @@ public function testThemeInfoAlter() { $this->assertFalse(isset($system_list[$name]->info['regions']['test_region'])); } + public function testThemeWithModuleDependency() { + $this->assertFalse($this->moduleHandler()->moduleExists('test_module_required_by_theme')); + $this->themeInstaller()->install(['test_theme_depending_on_module']); + $this->assertTrue($this->moduleHandler()->moduleExists('test_module_required_by_theme')); + + $service = \Drupal::service('test_module_required_by_theme.service'); + $this->assertInstanceOf(\Drupal\test_module_required_by_theme\Service::class, $service); + } + /** * Returns the theme handler service. * diff --git a/core/themes/stable/templates/admin/system-themes-page.html.twig b/core/themes/stable/templates/admin/system-themes-page.html.twig index 5a23f1a14c..a191606190 100644 --- a/core/themes/stable/templates/admin/system-themes-page.html.twig +++ b/core/themes/stable/templates/admin/system-themes-page.html.twig @@ -22,6 +22,7 @@ * - notes: Identifies what context this theme is being used in, e.g., * default theme, admin theme. * - incompatible: Text describing any compatibility issues. + * - module_dependencies: A list of modules that this theme requires. * - operations: A list of operation links, e.g., Settings, Enable, Disable, * etc. these links should only be displayed if the theme is compatible. * @@ -60,6 +61,9 @@ {%- endif -%}
{{ theme.description }}
+ {% if theme.module_dependencies %} +
Requires: {{ theme.module_dependencies }}
+ {% endif %} {# Display operation links if the theme is compatible. #} {% if theme.incompatible %}
{{ theme.incompatible }}