diff --git a/core/core.services.yml b/core/core.services.yml
index 93e57d0fb4..f3282256e1 100644
--- a/core/core.services.yml
+++ b/core/core.services.yml
@@ -521,7 +521,7 @@ services:
class: Drupal\Core\Extension\ModuleInstaller
tags:
- { name: service_collector, tag: 'module_install.uninstall_validator', call: addUninstallValidator }
- arguments: ['@app.root', '@module_handler', '@kernel']
+ arguments: ['@app.root', '@module_handler', '@kernel', '@extension.list.theme']
lazy: true
extension.list.module:
class: Drupal\Core\Extension\ModuleExtensionList
@@ -547,6 +547,12 @@ services:
- { name: module_install.uninstall_validator }
arguments: ['@string_translation', '@extension.list.module']
lazy: true
+ module_required_by_themes_uninstall_validator:
+ class: Drupal\Core\Extension\ModuleRequiredByThemesUninstallValidator
+ tags:
+ - { name: module_install.uninstall_validator }
+ arguments: ['@string_translation', '@extension.list.module', '@extension.list.theme']
+ lazy: true
theme_handler:
class: Drupal\Core\Extension\ThemeHandler
arguments: ['@app.root', '@config.factory', '@extension.list.theme']
diff --git a/core/lib/Drupal/Core/Extension/InfoParserDynamic.php b/core/lib/Drupal/Core/Extension/InfoParserDynamic.php
index 2e1dae399d..94d6e3c6d6 100644
--- a/core/lib/Drupal/Core/Extension/InfoParserDynamic.php
+++ b/core/lib/Drupal/Core/Extension/InfoParserDynamic.php
@@ -120,6 +120,19 @@ public function parse($filename) {
$parsed_info['install'] = $parsed_info['dependencies'];
$parsed_info['dependencies'] = [];
}
+ // Module dependencies declared by themes should be in machine name
+ // format. The ability to specify version constraints or parse project
+ // prefixes will be available when it becomes possible for composer.json
+ // to support dependency metadata.
+ //
+ // @see https://www.drupal.org/project/drupal/issues/3005229
+ if ($parsed_info['type'] === 'theme' && isset($parsed_info['dependencies'])) {
+ $non_machine_name_dependencies = preg_grep('/[^a-z0-9_]+/', $parsed_info['dependencies']);
+ if (!empty($non_machine_name_dependencies)) {
+ throw new InfoParserException("Theme module dependencies must be machine names (only underscores, digits, and lowercase letters). At least one dependency declared by {$parsed_info['name']} is not in machine name format: " . implode(',', $non_machine_name_dependencies));
+ }
+
+ }
}
return $parsed_info;
}
diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php
index 70157580d4..9866739ba0 100644
--- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php
+++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php
@@ -50,6 +50,13 @@ class ModuleInstaller implements ModuleInstallerInterface {
*/
protected $uninstallValidators;
+ /**
+ * The theme extension list.
+ *
+ * @var \Drupal\Core\Extension\ThemeExtensionList
+ */
+ protected $themeExtensionList;
+
/**
* Constructs a new ModuleInstaller instance.
*
@@ -59,14 +66,21 @@ class ModuleInstaller implements ModuleInstallerInterface {
* The module handler.
* @param \Drupal\Core\DrupalKernelInterface $kernel
* The drupal kernel.
+ * @param \Drupal\Core\Extension\ThemeExtensionList $extension_list_theme
+ * The theme extension list.
*
* @see \Drupal\Core\DrupalKernel
* @see \Drupal\Core\CoreServiceProvider
*/
- public function __construct($root, ModuleHandlerInterface $module_handler, DrupalKernelInterface $kernel) {
+ public function __construct($root, ModuleHandlerInterface $module_handler, DrupalKernelInterface $kernel, ThemeExtensionList $extension_list_theme = NULL) {
$this->root = $root;
$this->moduleHandler = $module_handler;
$this->kernel = $kernel;
+ if (is_null($extension_list_theme)) {
+ @trigger_error('The extension.list.theme service must be passed to ' . __NAMESPACE__ . '\ModuleInstaller::__construct. It was added in Drupal 8.9.0 and will be required before Drupal 10.0.0.', E_USER_DEPRECATED);
+ $extension_list_theme = \Drupal::service('extension.list.theme');
+ }
+ $this->themeExtensionList = $extension_list_theme;
}
/**
@@ -369,12 +383,14 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) {
}
if ($uninstall_dependents) {
+ $theme_list = $this->themeExtensionList->getList();
+
// Add dependent modules to the list. The new modules will be processed as
// the foreach loop continues.
foreach ($module_list as $module => $value) {
foreach (array_keys($module_data[$module]->required_by) as $dependent) {
- if (!isset($module_data[$dependent])) {
- // The dependent module does not exist.
+ if (!isset($module_data[$dependent]) && !isset($theme_list[$dependent])) {
+ // The dependent module or theme does not exist.
return FALSE;
}
diff --git a/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php
new file mode 100644
index 0000000000..0598b031e1
--- /dev/null
+++ b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php
@@ -0,0 +1,84 @@
+stringTranslation = $string_translation;
+ $this->moduleExtensionList = $extension_list_module;
+ $this->themeExtensionList = $extension_list_theme;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate($module) {
+ $reasons = [];
+
+ $themes_depending_on_module = $this->getThemesDependingOnModule($module);
+ if (!empty($themes_depending_on_module)) {
+ $module_name = $this->moduleExtensionList->get($module)->info['name'];
+ $theme_names = implode(", ", $themes_depending_on_module);
+ $reasons[] = $this->formatPlural(count($themes_depending_on_module),
+ 'Required by the theme: @theme_names',
+ 'Required by the themes: @theme_names',
+ ['@module_name' => $module_name, '@theme_names' => $theme_names]);
+ }
+
+ return $reasons;
+ }
+
+ /**
+ * Returns themes that depend on a module.
+ *
+ * @param string $module
+ * The module machine name.
+ *
+ * @return string[]
+ * An array of the names of themes that depend on $module.
+ */
+ protected function getThemesDependingOnModule($module) {
+ $installed_themes = $this->themeExtensionList->getAllInstalledInfo();
+ $themes_depending_on_module = array_map(function ($theme) use ($module) {
+ if (in_array($module, $theme['dependencies'])) {
+ return $theme['name'];
+ }
+ }, $installed_themes);
+
+ return array_filter($themes_depending_on_module);
+ }
+
+}
diff --git a/core/lib/Drupal/Core/Extension/ThemeExtensionList.php b/core/lib/Drupal/Core/Extension/ThemeExtensionList.php
index 5722d1c2fe..d6b13be8c0 100644
--- a/core/lib/Drupal/Core/Extension/ThemeExtensionList.php
+++ b/core/lib/Drupal/Core/Extension/ThemeExtensionList.php
@@ -51,6 +51,7 @@ class ThemeExtensionList extends ExtensionList {
'libraries' => [],
'libraries_extend' => [],
'libraries_override' => [],
+ 'dependencies' => [],
];
/**
@@ -140,6 +141,16 @@ protected function doList() {
// sub-themes.
$this->fillInSubThemeData($themes, $sub_themes);
+ foreach ($themes as $key => $theme) {
+ // After $theme is processed by buildModuleDependencies(), there is a
+ // `$theme->requires` array containing both module and base theme
+ // dependencies. The module dependencies are copied to their own property
+ // so they are available to operations specific to module dependencies.
+ if (!isset($theme->requires)) {
+ $theme->requires = [];
+ }
+ $themes[$key]->module_dependencies = isset($theme->requires) ? array_diff_key($theme->requires, $themes) : [];
+ }
return $themes;
}
diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php
index e1149e7b66..59adbae578 100644
--- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php
+++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php
@@ -106,6 +106,8 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
$extension_config = $this->configFactory->getEditable('core.extension');
$theme_data = $this->themeHandler->rebuildThemeData();
+ $installed_themes = $this->configFactory->get('core.extension')->get('theme') ?: [];
+ $installed_modules = $this->configFactory->get('core.extension')->get('module') ?: [];
if ($install_dependencies) {
$theme_list = array_combine($theme_list, $theme_list);
@@ -116,16 +118,25 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
}
// Only process themes that are not installed currently.
- $installed_themes = $extension_config->get('theme') ?: [];
if (!$theme_list = array_diff_key($theme_list, $installed_themes)) {
// Nothing to do. All themes already installed.
return 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);
+ $unmet_module_dependencies = array_diff_key($module_dependencies, $installed_modules);
+
+ // Prevent themes with unmet module dependencies from being installed.
+ if (!empty($unmet_module_dependencies)) {
+ $unmet_module_dependencies_list = implode(', ', array_keys($unmet_module_dependencies));
+ throw new MissingDependencyException("Unable to install theme: '$theme' due to unmet module dependencies: '$unmet_module_dependencies_list.'");
+ }
+
+ // 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;
@@ -147,9 +158,6 @@ public function install(array $theme_list, $install_dependencies = TRUE) {
arsort($theme_list);
$theme_list = array_keys($theme_list);
}
- else {
- $installed_themes = $extension_config->get('theme') ?: [];
- }
$themes_installed = [];
foreach ($theme_list as $key) {
diff --git a/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php b/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php
index ae79b505ea..6f18248cf9 100644
--- a/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php
+++ b/core/lib/Drupal/Core/Extension/ThemeInstallerInterface.php
@@ -25,6 +25,9 @@ interface ThemeInstallerInterface {
*
* @throws \Drupal\Core\Extension\Exception\UnknownExtensionException
* Thrown when the theme does not exist.
+ *
+ * @throws \Drupal\Core\Extension\MissingDependencyException
+ * Thrown when a requested dependency can't be found.
*/
public function install(array $theme_list, $install_dependencies = TRUE);
diff --git a/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php
new file mode 100644
index 0000000000..601f482e4d
--- /dev/null
+++ b/core/lib/Drupal/Core/ProxyClass/Extension/ModuleRequiredByThemesUninstallValidator.php
@@ -0,0 +1,88 @@
+container = $container;
+ $this->drupalProxyOriginalServiceId = $drupal_proxy_original_service_id;
+ }
+
+ /**
+ * Lazy loads the real service from the container.
+ *
+ * @return object
+ * Returns the constructed real service.
+ */
+ protected function lazyLoadItself()
+ {
+ if (!isset($this->service)) {
+ $this->service = $this->container->get($this->drupalProxyOriginalServiceId);
+ }
+
+ return $this->service;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function validate($module)
+ {
+ return $this->lazyLoadItself()->validate($module);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setStringTranslation(\Drupal\Core\StringTranslation\TranslationInterface $translation)
+ {
+ return $this->lazyLoadItself()->setStringTranslation($translation);
+ }
+
+ }
+
+}
diff --git a/core/modules/system/src/Controller/SystemController.php b/core/modules/system/src/Controller/SystemController.php
index f27cc01eb1..dd16ce349a 100644
--- a/core/modules/system/src/Controller/SystemController.php
+++ b/core/modules/system/src/Controller/SystemController.php
@@ -4,12 +4,14 @@
use Drupal\Core\Cache\CacheableMetadata;
use Drupal\Core\Controller\ControllerBase;
+use Drupal\Core\Extension\ModuleExtensionList;
use Drupal\Core\Extension\ThemeHandlerInterface;
use Drupal\Core\Form\FormBuilderInterface;
use Drupal\Core\Menu\MenuLinkTreeInterface;
use Drupal\Core\Menu\MenuTreeParameters;
use Drupal\Core\Theme\ThemeAccessCheck;
use Drupal\Core\Url;
+use Drupal\system\ModuleDependencyMessageTrait;
use Drupal\system\SystemManager;
use Symfony\Component\DependencyInjection\ContainerInterface;
@@ -18,6 +20,8 @@
*/
class SystemController extends ControllerBase {
+ use ModuleDependencyMessageTrait;
+
/**
* System Manager Service.
*
@@ -53,6 +57,13 @@ class SystemController extends ControllerBase {
*/
protected $menuLinkTree;
+ /**
+ * The module extension list.
+ *
+ * @var \Drupal\Core\Extension\ModuleExtensionList
+ */
+ protected $moduleExtensionList;
+
/**
* Constructs a new SystemController.
*
@@ -66,13 +77,20 @@ class SystemController extends ControllerBase {
* The theme handler.
* @param \Drupal\Core\Menu\MenuLinkTreeInterface $menu_link_tree
* The menu link tree service.
+ * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list
+ * The module extension list.
*/
- public function __construct(SystemManager $systemManager, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuLinkTreeInterface $menu_link_tree) {
+ public function __construct(SystemManager $systemManager, ThemeAccessCheck $theme_access, FormBuilderInterface $form_builder, ThemeHandlerInterface $theme_handler, MenuLinkTreeInterface $menu_link_tree, ModuleExtensionList $module_extension_list = NULL) {
$this->systemManager = $systemManager;
$this->themeAccess = $theme_access;
$this->formBuilder = $form_builder;
$this->themeHandler = $theme_handler;
$this->menuLinkTree = $menu_link_tree;
+ if ($module_extension_list === NULL) {
+ @trigger_error('The extension.list.module service must be passed to ' . __NAMESPACE__ . '\SystemController::__construct. It was added in Drupal 8.9.0 and will be required before Drupal 10.0.0.', E_USER_DEPRECATED);
+ $module_extension_list = \Drupal::service('extension.list.module');
+ }
+ $this->moduleExtensionList = $module_extension_list;
}
/**
@@ -84,7 +102,8 @@ public static function create(ContainerInterface $container) {
$container->get('access_check.theme'),
$container->get('form_builder'),
$container->get('theme_handler'),
- $container->get('menu.link_tree')
+ $container->get('menu.link_tree'),
+ $container->get('extension.list.module')
);
}
@@ -231,9 +250,44 @@ 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;
+ // Confirm that the user has permission to enable modules.
+ $theme->insufficient_module_permissions = FALSE;
}
+
+ // Check module dependencies.
+ if ($theme->module_dependencies) {
+ $modules = $this->moduleExtensionList->getList();
+ foreach ($theme->module_dependencies as $dependency => $dependency_object) {
+ if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) {
+ $theme->module_dependencies_list[$dependency] = $incompatible;
+ $theme->incompatible_module = TRUE;
+ continue;
+ }
+
+ // Only display visible modules.
+ if (!empty($modules[$dependency]->hidden)) {
+ continue;
+ }
+
+ $module_name = $modules[$dependency]->info['name'];
+ $theme->module_dependencies_list[$dependency] = $modules[$dependency]->status ? $this->t('@module_name', ['@module_name' => $module_name]) : $this->t('@module_name (disabled)', ['@module_name' => $module_name]);
+
+ // Create an additional property that contains only disabled module
+ // dependencies. This will determine if it is possible to install the
+ // theme, or if modules must first be enabled.
+ if (!$modules[$dependency]->status) {
+ $theme->module_dependencies_disabled[$dependency] = $module_name;
+ if (!$this->currentUser()->hasPermission('administer modules')) {
+ $theme->insufficient_module_permissions = TRUE;
+ }
+ }
+ }
+ }
+
$theme->operations = [];
- if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine) {
+ if (!empty($theme->status) || !$theme->info['core_incompatible'] && !$theme->incompatible_php && !$theme->incompatible_base && !$theme->incompatible_engine && !$theme->incompatible_module && empty($theme->module_dependencies_disabled)) {
// Create the operations links.
$query['theme'] = $theme->getName();
if ($this->themeAccess->checkAccess($theme->getName())) {
diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php
index f32dad0909..45a57d7d12 100644
--- a/core/modules/system/src/Form/ModulesListForm.php
+++ b/core/modules/system/src/Form/ModulesListForm.php
@@ -17,6 +17,7 @@
use Drupal\Core\Session\AccountInterface;
use Drupal\user\PermissionHandlerInterface;
use Drupal\Core\Url;
+use Drupal\system\ModuleDependencyMessageTrait;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
@@ -31,6 +32,8 @@
*/
class ModulesListForm extends FormBase {
+ use ModuleDependencyMessageTrait;
+
/**
* The current user.
*
@@ -326,38 +329,19 @@ protected function buildRow(array $modules, Extension $module, $distribution) {
// If this module requires other modules, add them to the array.
/** @var \Drupal\Core\Extension\Dependency $dependency_object */
foreach ($module->requires as $dependency => $dependency_object) {
- if (!isset($modules[$dependency])) {
- $row['#requires'][$dependency] = $this->t('@module (missing)', ['@module' => $dependency]);
- $row['enable']['#disabled'] = TRUE;
+ // Only display missing or visible modules.
+ if (!empty($modules[$dependency]->hidden)) {
+ continue;
}
- // 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 (!$dependency_object->isCompatible(str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $modules[$dependency]->info['version']))) {
- $row['#requires'][$dependency] = $this->t('@module (@constraint) (incompatible with version @version)', [
- '@module' => $name,
- '@constraint' => $dependency_object->getConstraintString(),
- '@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_incompatible']) {
- $row['#requires'][$dependency] = $this->t('@module (incompatible with this version of Drupal core)', [
- '@module' => $name,
- ]);
- $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]);
- }
+
+ if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) {
+ $row['#requires'][$dependency] = $incompatible;
+ $row['enable']['#disabled'] = TRUE;
+ continue;
}
+
+ $name = $modules[$dependency]->info['name'];
+ $row['#requires'][$dependency] = $modules[$dependency]->status ? $this->t('@module', ['@module' => $name]) : $this->t('@module (disabled)', ['@module' => $name]);
}
// If this module is required by other modules, list those, and then make it
diff --git a/core/modules/system/src/ModuleDependencyMessageTrait.php b/core/modules/system/src/ModuleDependencyMessageTrait.php
new file mode 100644
index 0000000000..31d3f12c69
--- /dev/null
+++ b/core/modules/system/src/ModuleDependencyMessageTrait.php
@@ -0,0 +1,53 @@
+t('@module_name (missing)', ['@module_name' => $dependency]);
+ }
+ else {
+ $module_name = $modules[$dependency]->info['name'];
+
+ // Check if the module is compatible with the installed version of core.
+ if ($modules[$dependency]->info['core_incompatible']) {
+ return $this->t('@module_name (incompatible with this version of Drupal core)', [
+ '@module_name' => $module_name,
+ ]);
+ }
+
+ // Check if the module is incompatible with the dependency constraints.
+ $version = str_replace(\Drupal::CORE_COMPATIBILITY . '-', '', $modules[$dependency]->info['version']);
+ if (!$dependency_object->isCompatible($version)) {
+ $constraint_string = $dependency_object->getConstraintString();
+ return $this->t('@module_name (incompatible with version @version)', [
+ '@module_name' => "$module_name ($constraint_string)",
+ '@version' => $modules[$dependency]->info['version'],
+ ]);
+ }
+ }
+ }
+
+}
diff --git a/core/modules/system/system.admin.inc b/core/modules/system/system.admin.inc
index 661677b51a..206ed3cdf3 100644
--- a/core/modules/system/system.admin.inc
+++ b/core/modules/system/system.admin.inc
@@ -9,6 +9,7 @@
use Drupal\Core\Link;
use Drupal\Core\Render\Element;
use Drupal\Core\Template\Attribute;
+use Drupal\Core\Url;
/**
* Prepares variables for administrative content block templates.
@@ -123,7 +124,7 @@ function template_preprocess_system_admin_index(&$variables) {
* - version: The version of the module.
* - links: Administration links provided by the module.
* - #requires: A list of modules that the project requires.
- * - #required_by: A list of modules that require the project.
+ * - #required_by: A list of modules and themes that require the project.
* - #attributes: A list of attributes for the module wrapper.
*
* @see \Drupal\system\Form\ModulesListForm
@@ -131,6 +132,18 @@ function template_preprocess_system_admin_index(&$variables) {
function template_preprocess_system_modules_details(&$variables) {
$form = $variables['form'];
+ // Identify modules that are depended on by themes.
+ // Added here instead of ModuleHandler to avoid recursion.
+ $themes = \Drupal::service('extension.list.theme')->getList();
+ foreach ($themes as $theme) {
+ foreach ($theme->info['dependencies'] as $dependency) {
+ if (isset($form[$dependency])) {
+ // Add themes to the module's required by list.
+ $form[$dependency]['#required_by'][] = $theme->info['name'] . ' (' . t('Theme') . ')' . (!$theme->status ? ' (' . t('Disabled') . ')' : '');
+ }
+ }
+ }
+
$variables['modules'] = [];
// Iterate through all the modules, which are children of this element.
foreach (Element::children($form) as $key) {
@@ -291,6 +304,12 @@ 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'] = !empty($theme->module_dependencies_list) ? [
+ '#theme' => 'item_list',
+ '#items' => $theme->module_dependencies_list,
+ '#context' => ['list_style' => 'comma-list'],
+ ] : [];
+
// Make sure to provide feedback on compatibility.
$current_theme['incompatible'] = '';
if (!empty($theme->info['core_incompatible'])) {
@@ -311,6 +330,20 @@ 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.');
+ }
+ elseif (!empty($theme->module_dependencies_disabled)) {
+ if (!empty($theme->insufficient_module_permissions)) {
+ $current_theme['incompatible'] = t('This theme requires the listed modules to operate correctly. They must first be enabled by a user with permissions to do so.');
+ }
+ else {
+ $modules_url = (string) Url::fromRoute('system.modules_list')->toString();
+ $current_theme['incompatible'] = t('This theme requires the listed modules to operate correctly. They must first be enabled via the Extend page.', [
+ ':modules_url' => $modules_url,
+ ]);
+ }
+ }
// 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/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php
index a210cd37a9..cef12ffcc7 100644
--- a/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php
+++ b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php
@@ -33,13 +33,7 @@ protected function setUp() {
* Tests the module list form.
*/
public function testModuleListForm() {
- $this->drupalLogin(
- $this->drupalCreateUser(
- ['administer modules', 'administer permissions']
- )
- );
- $this->drupalGet('admin/modules');
- $this->assertResponse('200');
+ $this->createUserAdministerModules();
// Check that system_test's configure link was rendered correctly.
$this->assertFieldByXPath("//a[contains(@href, '/system-test/configure/bar') and text()='Configure ']/span[contains(@class, 'visually-hidden') and text()='the System test module']");
@@ -65,13 +59,7 @@ public function testModulesListFormWithInvalidInfoFile() {
mkdir($path, 0777, TRUE);
file_put_contents("$path/broken.info.yml", $broken_info_yml);
- $this->drupalLogin(
- $this->drupalCreateUser(
- ['administer modules', 'administer permissions']
- )
- );
- $this->drupalGet('admin/modules');
- $this->assertSession()->statusCodeEquals(200);
+ $this->createUserAdministerModules();
// Confirm that the error message is shown.
$this->assertSession()
@@ -81,4 +69,33 @@ public function testModulesListFormWithInvalidInfoFile() {
$this->assertSession()->elementExists('xpath', '//input[@name="text"]');
}
+ /**
+ * Confirm that module 'Required By' descriptions include dependent themes.
+ */
+ public function testRequiredByThemeMessage() {
+ $this->createUserAdministerModules();
+ $module_theme_depends_on_description = $this->getSession()->getPage()->findAll('css', '#edit-modules-test-module-required-by-theme-enable-description .admin-requirements li:contains("Test Theme Depending on Modules (Theme) (Disabled)")');
+
+ // Confirm that that 'Test Theme Depending on Modules' is listed as being
+ // required by the module 'Test Module Required by Theme'.
+ $this->assertCount(1, $module_theme_depends_on_description);
+
+ // Confirm that the required by message does not appear anywhere else.
+ $this->assertSession()->pageTextContains('Test Theme Depending on Modules (Theme) (Disabled)');
+ }
+
+ /**
+ * Creates user that can administer modules then visits `admin/modules`.
+ */
+ protected function createUserAdministerModules() {
+ $this->drupalLogin(
+ $this->drupalCreateUser(
+ ['administer modules', 'administer permissions']
+ )
+ );
+
+ $this->drupalGet('admin/modules');
+ $this->assertSession()->statusCodeEquals(200);
+ }
+
}
diff --git a/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php
new file mode 100644
index 0000000000..44f9f53809
--- /dev/null
+++ b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php
@@ -0,0 +1,319 @@
+drupalLogin($this->drupalCreateUser([
+ 'administer themes',
+ 'administer modules',
+ ]));
+ }
+
+ /**
+ * Tests permissions for enabling themes depending on disabled modules.
+ */
+ public function testModulePermissions() {
+ // Log in as a user without permission to enable modules.
+ $this->drupalLogin($this->drupalCreateUser([
+ 'administer themes',
+ ]));
+ $this->drupalGet('admin/appearance');
+
+ // The links to install a theme that would enable modules should be replaced
+ // by this message.
+ $this->assertSession()->pageTextContains('This theme requires the listed modules to operate correctly. They must first be enabled by a user with permissions to do so.');
+
+ // The install page should not be reachable.
+ $this->drupalGet('admin/appearance/install?theme=test_theme_depending_on_modules');
+ $this->assertSession()->statusCodeEquals(404);
+
+ $this->drupalLogin($this->drupalCreateUser([
+ 'administer themes',
+ 'administer modules',
+ ]));
+ $this->drupalGet('admin/appearance');
+ $this->assertSession()->pageTextNotContains('This theme requires the listed modules to operate correctly. They must first be enabled by a user with permissions to do so.');
+ }
+
+ /**
+ * Tests installing a theme with module dependencies.
+ *
+ * @dataProvider providerTestThemeInstallWithModuleDependencies
+ */
+ public function testThemeInstallWithModuleDependencies($theme_name, array $first_expected_required_list_items, array $first_module_enable, array $first_confirm_checked, array $second_expected_required_list_items, array $second_module_enable, array $second_confirm_checked, array $module_names, array $required_by_messages, $module_uninstall_message, $base_theme_to_uninstall, array $base_theme_module_names, $base_theme_module_uninstall_message) {
+ $assert_session = $this->assertSession();
+ $page = $this->getSession()->getPage();
+ $this->drupalGet('admin/appearance');
+
+ $this->assertUninstallableTheme($first_expected_required_list_items, $theme_name);
+
+ // Enable one of the two required modules.
+ $this->drupalPostForm('admin/modules', $first_module_enable, 'Install');
+ foreach ($first_confirm_checked as $selector) {
+ $this->assertSession()->elementExists('css', $selector);
+ }
+
+ $this->drupalGet('admin/appearance');
+
+ // Confirm the theme is still uninstallable due to a remaining module
+ // dependency.
+ $this->assertUninstallableTheme($second_expected_required_list_items, $theme_name);
+ $this->drupalPostForm('admin/modules', $second_module_enable, 'Install');
+ foreach ($second_confirm_checked as $selector) {
+ $this->assertSession()->elementExists('css', $selector);
+ }
+
+ // The theme should now be installable, so install it.
+ $this->drupalGet('admin/appearance');
+ $page->clickLink("Install $theme_name theme");
+ $assert_session->addressEquals('admin/appearance');
+ $assert_session->pageTextContains("The $theme_name theme has been installed");
+
+ // Confirm that the dependee modules can't be uninstalled because an enabled
+ // theme depends on them.
+ $this->drupalGet('admin/modules/uninstall');
+ foreach ($module_names as $attribute) {
+ $assert_session->elementExists('css', "[name=\"uninstall[$attribute]\"][disabled]");
+ }
+ foreach ($required_by_messages as $selector => $message) {
+ $assert_session->elementTextContains('css', $selector, $message);
+ }
+
+ // Uninstall the theme that depends on the modules, and confirm the modules
+ // can now be uninstalled.
+ $this->uninstallTheme($theme_name);
+ $this->drupalGet('admin/modules/uninstall');
+
+ // Only attempt to uninstall modules not required by the base theme.
+ $modules_to_uninstall = array_diff($module_names, $base_theme_module_names);
+ $this->uninstallModules($modules_to_uninstall, $module_uninstall_message);
+
+ if (!empty($base_theme_to_uninstall)) {
+ $base_theme_name = $base_theme_to_uninstall;
+ $this->uninstallTheme($base_theme_name);
+ $this->drupalGet('admin/modules/uninstall');
+ $this->uninstallModules($base_theme_module_names, $base_theme_module_uninstall_message);
+ }
+ }
+
+ /**
+ * Uninstalls modules via the admin UI.
+ *
+ * @param string[] $module_names
+ * An array of module machine names.
+ * @param string $confirmation_message_list
+ * The list of modules included in the confirmation.
+ */
+ protected function uninstallModules(array $module_names, $confirmation_message_list) {
+ $assert_session = $this->assertSession();
+ $this->drupalGet('admin/modules/uninstall');
+ foreach ($module_names as $attribute) {
+ $assert_session->elementExists('css', "[name=\"uninstall[$attribute]\"]:not([disabled])");
+ }
+ $to_uninstall = [];
+ foreach ($module_names as $attribute) {
+ $to_uninstall["uninstall[$attribute]"] = 1;
+ }
+ if (!empty($to_uninstall)) {
+ $this->drupalPostForm('admin/modules/uninstall', $to_uninstall, 'Uninstall');
+ $confirmation_message = 'The following modules will be completely uninstalled from your site, and all data from these modules will be lost!' . $confirmation_message_list . 'Would you like to continue with uninstalling the above?';
+ $assert_session->pageTextContains($confirmation_message);
+ $this->getSession()->getPage()->pressButton('Uninstall');
+ $assert_session->pageTextContains('The selected modules have been uninstalled.');
+ }
+ }
+
+ /**
+ * Uninstalls a theme via the admin UI.
+ *
+ * @param string $theme_name
+ * The theme name.
+ */
+ protected function uninstallTheme($theme_name) {
+ $this->drupalGet('admin/appearance');
+ $this->clickLink("Uninstall $theme_name theme");
+ $this->assertSession()->pageTextContains("The $theme_name theme has been uninstalled.");
+ }
+
+ /**
+ * Data provider for testThemeInstallWithModuleDependencies().
+ */
+ public function providerTestThemeInstallWithModuleDependencies() {
+ return [
+ 'test theme with a module dependency and base theme with a different module dependency' => [
+ 'theme_name' => 'Test Theme with a Module Dependency and Base Theme with a Different Module Dependency',
+ 'first_expected_required_list_items' => [
+ 'Help (disabled)',
+ 'Test Module Required by Theme (disabled)',
+ 'Test Another Module Required by Theme (disabled)',
+ ],
+ 'first_module_enable' => [
+ 'modules[test_module_required_by_theme][enable]' => 1,
+ 'modules[test_another_module_required_by_theme][enable]' => 1,
+ ],
+ 'first_confirm_checked' => [
+ '#edit-modules-test-module-required-by-theme-enable[checked]',
+ '#edit-modules-test-another-module-required-by-theme-enable[checked]',
+ ],
+ 'second_expected_required_list_items' => [
+ 'Help (disabled)',
+ 'Test Module Required by Theme',
+ 'Test Another Module Required by Theme',
+ ],
+ 'second_module_enable' => [
+ 'modules[help][enable]' => 1,
+ ],
+ 'second_confirm_checked' => [
+ '#edit-modules-test-module-required-by-theme-enable[checked]',
+ '#edit-modules-test-another-module-required-by-theme-enable[checked]',
+ '#edit-modules-help-enable[checked]',
+ ],
+ 'module_names' => [
+ 'help',
+ 'test_module_required_by_theme',
+ 'test_another_module_required_by_theme',
+ ],
+ 'required_by_messages' => [
+ '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+ '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+ '[data-drupal-selector="edit-help"] .item-list' => 'Required by the theme: Test Theme with a Module Dependency and Base Theme with a Different Module Dependency',
+ ],
+ 'module_uninstall_message' => 'help',
+ 'base_theme_to_uninstall' => 'Test Theme Depending on Modules',
+ 'base_theme_module_names' => [
+ 'test_module_required_by_theme',
+ 'test_another_module_required_by_theme',
+ ],
+ 'base_theme_module_uninstall_message' => 'Test Another Module Required by ThemeTest Module Required by Theme',
+ ],
+ 'Test Theme Depending on Modules' => [
+ 'theme_name' => 'Test Theme Depending on Modules',
+ 'first_expected_required_list_items' => [
+ 'Test Module Required by Theme (disabled)',
+ 'Test Another Module Required by Theme (disabled)',
+ ],
+ 'first_module_enable' => [
+ 'modules[test_module_required_by_theme][enable]' => 1,
+ ],
+ 'first_confirm_checked' => [
+ '#edit-modules-test-module-required-by-theme-enable[checked]',
+ ],
+ 'second_expected_required_list_items' => [
+ 'Test Module Required by Theme',
+ 'Test Another Module Required by Theme (disabled)',
+ ],
+ 'second_module_enable' => [
+ 'modules[test_another_module_required_by_theme][enable]' => 1,
+ ],
+ 'second_confirm_checked' => [
+ '#edit-modules-test-module-required-by-theme-enable[checked]',
+ '#edit-modules-test-another-module-required-by-theme-enable[checked]',
+ ],
+ 'module_names' => [
+ 'test_module_required_by_theme',
+ 'test_another_module_required_by_theme',
+ ],
+ 'required_by_messages' => [
+ '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+ '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+ ],
+ 'module_uninstall_message' => 'Test Another Module Required by ThemeTest Module Required by Theme',
+ 'base_theme_to_uninstall' => '',
+ 'base_theme_module_names' => [],
+ 'base_theme_module_uninstall_message' => '',
+ ],
+ 'test theme with a base theme depending on modules' => [
+ 'theme_name' => 'Test Theme with a Base Theme Depending on Modules',
+ 'first_expected_required_list_items' => [
+ 'Test Module Required by Theme (disabled)',
+ 'Test Another Module Required by Theme (disabled)',
+ ],
+ 'first_module_enable' => [
+ 'modules[test_module_required_by_theme][enable]' => 1,
+ ],
+ 'first_confirm_checked' => [
+ '#edit-modules-test-module-required-by-theme-enable[checked]',
+ ],
+ 'second_expected_required_list_items' => [
+ 'Test Module Required by Theme',
+ 'Test Another Module Required by Theme (disabled)',
+ ],
+ 'second_module_enable' => [
+ 'modules[test_another_module_required_by_theme][enable]' => 1,
+ ],
+ 'second_confirm_checked' => [
+ '#edit-modules-test-module-required-by-theme-enable[checked]',
+ '#edit-modules-test-another-module-required-by-theme-enable[checked]',
+ ],
+ 'module_names' => [
+ 'test_module_required_by_theme',
+ 'test_another_module_required_by_theme',
+ ],
+ 'required_by_messages' => [
+ '[data-drupal-selector="edit-test-another-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+ '[data-drupal-selector="edit-test-module-required-by-theme"] .item-list' => 'Required by the theme: Test Theme Depending on Modules',
+ ],
+ 'module_uninstall_message' => '',
+ 'base_theme_to_uninstall' => 'Test Theme Depending on Modules',
+ 'base_theme_module_names' => [
+ 'test_module_required_by_theme',
+ 'test_another_module_required_by_theme',
+ ],
+ 'base_theme_module_uninstall_message' => 'Test Another Module Required by ThemeTest Module Required by Theme',
+ ],
+ ];
+ }
+
+ /**
+ * Checks related to uninstallable themes due to module dependencies.
+ *
+ * @param string[] $expected_requires_list_items
+ * The modules listed as being required to install the theme.
+ * @param string $theme_name
+ * The name of the theme.
+ */
+ protected function assertUninstallableTheme(array $expected_requires_list_items, $theme_name) {
+ $theme_container = $this->getSession()->getPage()->find('css', "h3:contains(\"$theme_name\")")->getParent();
+ $requires_list_items = $theme_container->findAll('css', '.theme-info__requires li');
+ $this->assertCount(count($expected_requires_list_items), $requires_list_items);
+
+ foreach ($requires_list_items as $key => $item) {
+ $this->assertSame($expected_requires_list_items[$key], $item->getText());
+ }
+
+ $incompatible = $theme_container->find('css', '.incompatible');
+ $expected_incompatible_text = 'This theme requires the listed modules to operate correctly. They must first be enabled via the Extend page.';
+ $this->assertSame($expected_incompatible_text, $incompatible->getText());
+ $this->assertFalse($theme_container->hasLink('Install Test Theme Depending on Modules theme'));
+ }
+
+ /**
+ * Tests installing a theme with missing module dependencies.
+ */
+ public function testInstallModuleWithMissingDependencies() {
+ $this->drupalGet('admin/appearance');
+ $theme_container = $this->getSession()->getPage()->find('css', 'h3:contains("Test Theme Depending on Nonexisting Module")')->getParent();
+ $this->assertContains('Requires: test_module_non_existing (missing)', $theme_container->getText());
+ $this->assertContains('This theme requires the listed modules to operate correctly.', $theme_container->getText());
+ }
+
+}
diff --git a/core/modules/system/tests/themes/test_theme_depending_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml b/core/modules/system/tests/themes/test_theme_depending_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml
new file mode 100644
index 0000000000..43c8ed477b
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml
@@ -0,0 +1,7 @@
+name: Test Theme Depending on Nonexisting Module
+type: theme
+core: 8.x
+base theme: stark
+version: VERSION
+dependencies:
+ - test_module_non_existing
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml
new file mode 100644
index 0000000000..4527338d46
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_another_module_required_by_theme/test_another_module_required_by_theme.info.yml
@@ -0,0 +1,5 @@
+name: Test Another Module Required by Theme
+type: module
+core: 8.x
+package: Testing
+version: VERSION
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml
new file mode 100644
index 0000000000..75fcd17ae5
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.info.yml
@@ -0,0 +1,5 @@
+name: Test Module Required by Theme
+type: module
+core: 8.x
+package: Testing
+version: VERSION
diff --git a/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml
new file mode 100644
index 0000000000..a20675c742
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml
@@ -0,0 +1,7 @@
+name: Test Theme Depending on Modules
+type: theme
+core: 8.x
+base theme: stark
+dependencies:
+ - test_module_required_by_theme
+ - test_another_module_required_by_theme
diff --git a/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml b/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml
new file mode 100644
index 0000000000..19aa00e49c
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml
@@ -0,0 +1,6 @@
+name: Test Theme with a Module Dependency and Base Theme with a Different Module Dependency
+type: theme
+core: 8.x
+base theme: test_theme_depending_on_modules
+dependencies:
+ - help
diff --git a/core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml b/core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml
new file mode 100644
index 0000000000..91391fdac8
--- /dev/null
+++ b/core/modules/system/tests/themes/test_theme_with_a_base_theme_depending_on_modules/test_theme_with_a_base_theme_depending_on_modules.info.yml
@@ -0,0 +1,4 @@
+name: Test Theme with a Base Theme Depending on Modules
+type: theme
+core: 8.x
+base theme: test_theme_depending_on_modules
diff --git a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php
index c8dd0337a8..7ff1ec3a12 100644
--- a/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php
+++ b/core/tests/Drupal/KernelTests/Core/Theme/ThemeInstallerTest.php
@@ -4,6 +4,8 @@
use Drupal\Core\DependencyInjection\ContainerBuilder;
use Drupal\Core\Extension\ExtensionNameLengthException;
+use Drupal\Core\Extension\MissingDependencyException;
+use Drupal\Core\Extension\ModuleUninstallValidatorException;
use Drupal\Core\Extension\Exception\UnknownExtensionException;
use Drupal\KernelTests\KernelTestBase;
@@ -137,6 +139,69 @@ public function testInstallNameTooLong() {
}
}
+ /**
+ * Tests installing a theme with unmet module dependencies.
+ *
+ * @dataProvider providerTestInstallThemeWithUnmetModuleDependencies
+ */
+ public function testInstallThemeWithUnmetModuleDependencies($theme_name, $missing_dependencies, $installed_modules) {
+ $this->container->get('module_installer')->install($installed_modules);
+ $themes = $this->themeHandler()->listInfo();
+ $this->assertEmpty(array_keys($themes));
+ $this->expectException(MissingDependencyException::class);
+ $this->expectExceptionMessage("Unable to install theme: '$theme_name' due to unmet module dependencies: '$missing_dependencies'");
+ $this->themeInstaller()->install([$theme_name]);
+ }
+
+ /**
+ * Data provider for testInstallThemeWithUnmetModuleDependencies().
+ */
+ public function providerTestInstallThemeWithUnmetModuleDependencies() {
+ return [
+ 'theme with uninstalled module dependencies' => [
+ 'test_theme_depending_on_modules',
+ 'test_module_required_by_theme, test_another_module_required_by_theme.',
+ [],
+ ],
+ 'theme with a base theme with uninstalled module dependencies' => [
+ 'test_theme_with_a_base_theme_depending_on_modules',
+ 'test_module_required_by_theme, test_another_module_required_by_theme.',
+ [],
+ ],
+ 'theme and base theme have uninstalled module dependencies' => [
+ 'test_theme_mixed_module_dependencies',
+ 'help, test_module_required_by_theme, test_another_module_required_by_theme.',
+ [],
+ ],
+ 'theme with already installed module dependencies, base theme module dependencies are not installed' => [
+ 'test_theme_mixed_module_dependencies',
+ 'test_module_required_by_theme, test_another_module_required_by_theme.',
+ ['help'],
+ ],
+ 'theme with module dependencies not installed, base theme module dependencies are already installed, ' => [
+ 'test_theme_mixed_module_dependencies',
+ 'help.',
+ ['test_module_required_by_theme', 'test_another_module_required_by_theme'],
+ ],
+ ];
+ }
+
+ /**
+ * Tests installing a theme with module dependencies that are met.
+ */
+ public function testInstallThemeWithMetModuleDependencies() {
+ $name = 'test_theme_depending_on_modules';
+ $themes = $this->themeHandler()->listInfo();
+ $this->assertFalse(isset($themes[$name]));
+ $this->container->get('module_installer')->install(['test_module_required_by_theme', 'test_another_module_required_by_theme']);
+ $this->themeInstaller()->install([$name]);
+ $themes = $this->themeHandler()->listInfo();
+ $this->assertTrue(isset($themes[$name]));
+ $this->expectException(ModuleUninstallValidatorException::class);
+ $this->expectExceptionMessage('The following reasons prevent the modules from being uninstalled: Required by the theme: Test Theme Depending on Modules');
+ $this->container->get('module_installer')->uninstall(['test_module_required_by_theme']);
+ }
+
/**
* Tests uninstalling the default theme.
*/
diff --git a/core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php b/core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php
new file mode 100644
index 0000000000..71537340a3
--- /dev/null
+++ b/core/tests/Drupal/Tests/Core/Extension/ModuleRequiredByThemesUninstallValidatorTest.php
@@ -0,0 +1,161 @@
+moduleExtensionList = $this->prophesize(ModuleExtensionList::class);
+ $this->themeExtensionList = $this->prophesize(ThemeExtensionList::class);
+ $this->moduleRequiredByThemeUninstallValidator = new ModuleRequiredByThemesUninstallValidator($this->getStringTranslationStub(), $this->moduleExtensionList->reveal(), $this->themeExtensionList->reveal());
+ }
+
+ /**
+ * @covers ::validate
+ */
+ public function testValidateNoThemeDependency() {
+ $this->themeExtensionList->getAllInstalledInfo()->willReturn([
+ 'stable' => [
+ 'name' => 'Stable',
+ 'dependencies' => [],
+ ],
+ 'claro' => [
+ 'name' => 'Claro',
+ 'dependencies' => [],
+ ],
+ ]);
+
+ $module = $this->randomMachineName();
+ $expected = [];
+ $reasons = $this->moduleRequiredByThemeUninstallValidator->validate($module);
+ $this->assertSame($expected, $reasons);
+ }
+
+ /**
+ * @covers ::validate
+ */
+ public function testValidateOneThemeDependency() {
+ $module = 'single_module';
+ $module_name = 'Single Module';
+ $theme = 'one_theme';
+ $theme_name = 'One Theme';
+ $this->themeExtensionList->getAllInstalledInfo()->willReturn([
+ 'stable' => [
+ 'name' => 'Stable',
+ 'dependencies' => [],
+ ],
+ 'claro' => [
+ 'name' => 'Claro',
+ 'dependencies' => [],
+ ],
+ $theme => [
+ 'name' => $theme_name,
+ 'dependencies' => [
+ $module,
+ ],
+ ],
+ ]);
+
+ $this->moduleExtensionList->get($module)->willReturn((object) [
+ 'info' => [
+ 'name' => $module_name,
+ ],
+ ]);
+
+ $expected = [
+ "Required by the theme: $theme_name",
+ ];
+
+ $reasons = $this->moduleRequiredByThemeUninstallValidator->validate($module);
+ $this->assertSame($expected, $this->castSafeStrings($reasons));
+ }
+
+ /**
+ * @covers ::validate
+ */
+ public function testValidateTwoThemeDependencies() {
+ $module = 'popular_module';
+ $module_name = 'Popular Module';
+ $theme1 = 'first_theme';
+ $theme2 = 'second_theme';
+ $theme_name_1 = 'First Theme';
+ $theme_name_2 = 'Second Theme';
+ $this->themeExtensionList->getAllInstalledInfo()->willReturn([
+ 'stable' => [
+ 'name' => 'Stable',
+ 'dependencies' => [],
+ ],
+ 'claro' => [
+ 'name' => 'Claro',
+ 'dependencies' => [],
+ ],
+ $theme1 => [
+ 'name' => $theme_name_1,
+ 'dependencies' => [
+ $module,
+ ],
+ ],
+ $theme2 => [
+ 'name' => $theme_name_2,
+ 'dependencies' => [
+ $module,
+ ],
+ ],
+ ]);
+
+ $this->moduleExtensionList->get($module)->willReturn((object) [
+ 'info' => [
+ 'name' => $module_name,
+ ],
+ ]);
+
+ $expected = [
+ "Required by the themes: $theme_name_1, $theme_name_2",
+ ];
+
+ $reasons = $this->moduleRequiredByThemeUninstallValidator->validate($module);
+ $this->assertSame($expected, $this->castSafeStrings($reasons));
+ }
+
+}
+
+if (!defined('DRUPAL_MINIMUM_PHP')) {
+ define('DRUPAL_MINIMUM_PHP', '7.0.8');
+}
diff --git a/core/themes/claro/templates/system-themes-page.html.twig b/core/themes/claro/templates/system-themes-page.html.twig
index 1108c7f2f6..6a340318cc 100644
--- a/core/themes/claro/templates/system-themes-page.html.twig
+++ b/core/themes/claro/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.
* - title_id: The unique id of the theme label.
@@ -97,6 +98,9 @@