diff --git a/core/core.services.yml b/core/core.services.yml index 93e57d0fb4..20f3ac145e 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -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', '@config.factory'] + 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/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index 70157580d4..089c271daa 100644 --- a/core/lib/Drupal/Core/Extension/ModuleInstaller.php +++ b/core/lib/Drupal/Core/Extension/ModuleInstaller.php @@ -369,12 +369,14 @@ public function uninstall(array $module_list, $uninstall_dependents = TRUE) { } if ($uninstall_dependents) { + $theme_list = \Drupal::service('extension.list.theme')->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..b3dc84dafd --- /dev/null +++ b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php @@ -0,0 +1,97 @@ +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->getModuleName($module); + $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; + } + + /** + * Gets the name of a module. + * + * @param string $module + * The module machine name. + * + * @return string + * The module name. + */ + protected function getModuleName($module) { + return $this->moduleExtensionList->get($module)->info['name']; + } + + /** + * 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..95334a1b5b 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->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 e1149e7b66..4993fa1cad 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); + $missing_module_dependencies = array_diff_key($module_dependencies, $installed_modules); + + // Prevent themes with unmet module dependencies from being installed. + if (!empty($missing_module_dependencies)) { + $missing_module_dependencies_list = implode(', ', array_keys($missing_module_dependencies)); + throw new MissingDependencyException("Unable to install theme: '$theme' due to missing module dependencies: '$missing_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..c848221dd2 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 9.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[$dependency] = $incompatible; + $theme->incompatible_module = TRUE; + continue; + } + + // Only display visible modules. + if (!empty($modules[$dependency]->hidden)) { + unset($theme->module_dependencies[$dependency]); + continue; + } + + $module_name = $modules[$dependency]->info['name']; + $theme->module_dependencies[$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..66165089e6 --- /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..ea33414fe9 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) ? [ + '#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->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 -%}