diff --git a/core/core.services.yml b/core/core.services.yml index 0b088918cf8..4b9117e8210 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -526,7 +526,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 @@ -552,12 +552,18 @@ 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'] theme_installer: class: Drupal\Core\Extension\ThemeInstaller - arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state'] + arguments: ['@theme_handler', '@config.factory', '@config.installer', '@module_handler', '@config.manager', '@asset.css.collection_optimizer', '@router.builder', '@logger.channel.default', '@state', '@extension.list.module'] # @deprecated in Drupal 8.0.x and will be removed before 9.0.0. Use the other # entity* services instead. entity.manager: diff --git a/core/lib/Drupal/Core/Extension/ModuleInstaller.php b/core/lib/Drupal/Core/Extension/ModuleInstaller.php index 17bb7003062..287a72c7dcd 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; } /** @@ -372,12 +386,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; } @@ -578,6 +594,7 @@ protected function updateKernel($module_filenames) { // After rebuilding the container we need to update the injected // dependencies. $container = $this->kernel->getContainer(); + $this->themeExtensionList = $container->get('extension.list.theme'); $this->moduleHandler = $container->get('module_handler'); } diff --git a/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php b/core/lib/Drupal/Core/Extension/ModuleRequiredByThemesUninstallValidator.php new file mode 100644 index 00000000000..86499f55cad --- /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 5722d1c2fea..d7a7ad60be3 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,22 @@ protected function doList() { // sub-themes. $this->fillInSubThemeData($themes, $sub_themes); + foreach ($themes as $key => $theme) { + // After $theme is processed by buildModuleDependencies(), there can be 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->module_dependencies = array_diff_key($theme->requires, $themes); + } + else { + // Even if no requirements are specified, the theme installation process + // expects the presence of the `requires` and `module_dependencies` + // properties, so they should be initialized here as empty arrays. + $theme->requires = []; + $theme->module_dependencies = []; + } + } return $themes; } diff --git a/core/lib/Drupal/Core/Extension/ThemeInstaller.php b/core/lib/Drupal/Core/Extension/ThemeInstaller.php index e1149e7b668..fdcb6ed931c 100644 --- a/core/lib/Drupal/Core/Extension/ThemeInstaller.php +++ b/core/lib/Drupal/Core/Extension/ThemeInstaller.php @@ -2,6 +2,7 @@ namespace Drupal\Core\Extension; +use Drupal\Component\Utility\Html; use Drupal\Core\Asset\AssetCollectionOptimizerInterface; use Drupal\Core\Cache\Cache; use Drupal\Core\Config\ConfigFactoryInterface; @@ -10,6 +11,8 @@ use Drupal\Core\Extension\Exception\UnknownExtensionException; use Drupal\Core\Routing\RouteBuilderInterface; use Drupal\Core\State\StateInterface; +use Drupal\Core\StringTranslation\StringTranslationTrait; +use Drupal\system\ModuleDependencyMessageTrait; use Psr\Log\LoggerInterface; /** @@ -17,6 +20,9 @@ */ class ThemeInstaller implements ThemeInstallerInterface { + use ModuleDependencyMessageTrait; + use StringTranslationTrait; + /** * @var \Drupal\Core\Extension\ThemeHandlerInterface */ @@ -62,6 +68,13 @@ class ThemeInstaller implements ThemeInstallerInterface { */ protected $logger; + /** + * The module extension list. + * + * @var \Drupal\Core\Extension\ModuleExtensionList + */ + protected $moduleExtensionList; + /** * Constructs a new ThemeInstaller. * @@ -86,8 +99,10 @@ class ThemeInstaller implements ThemeInstallerInterface { * A logger instance. * @param \Drupal\Core\State\StateInterface $state * The state store. + * @param \Drupal\Core\Extension\ModuleExtensionList $module_extension_list + * The module extension list. */ - 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, ModuleExtensionList $module_extension_list = NULL) { $this->themeHandler = $theme_handler; $this->configFactory = $config_factory; $this->configInstaller = $config_installer; @@ -97,6 +112,11 @@ public function __construct(ThemeHandlerInterface $theme_handler, ConfigFactoryI $this->routeBuilder = $route_builder; $this->logger = $logger; $this->state = $state; + if ($module_extension_list === NULL) { + @trigger_error('The extension.list.module service must be passed to ' . __NAMESPACE__ . '\ThemeInstaller::__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; } /** @@ -106,6 +126,8 @@ public function install(array $theme_list, $install_dependencies = TRUE) { $extension_config = $this->configFactory->getEditable('core.extension'); $theme_data = $this->themeHandler->rebuildThemeData(); + $installed_themes = $extension_config->get('theme') ?: []; + $installed_modules = $extension_config->get('module') ?: []; if ($install_dependencies) { $theme_list = array_combine($theme_list, $theme_list); @@ -116,16 +138,41 @@ 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; } + $module_list = $this->moduleExtensionList->getList(); 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_data[$theme]->requires contains both theme and module + // dependencies keyed by the extension machine names and + // $theme_data[$theme]->module_dependencies contains only modules keyed + // by the module extension machine name. Therefore we can find the theme + // dependencies by finding array keys for 'requires' that are not in + // $module_dependencies. + $theme_dependencies = array_diff_key($theme_data[$theme]->requires, $module_dependencies); + // We can find the unmet module dependencies by finding the module + // machine names keys that are not in $installed_modules keys. + $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'."); + } + + foreach ($module_dependencies as $dependency => $dependency_object) { + if ($incompatible = $this->checkDependencyMessage($module_list, $dependency, $dependency_object)) { + $sanitized_message = Html::decodeEntities(strip_tags($incompatible)); + throw new MissingDependencyException("Unable to install theme: $sanitized_message"); + } + } + + // 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 +194,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 ae79b505ea1..6f18248cf91 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 00000000000..601f482e4d3 --- /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 f27cc01eb11..5942ff3ce0f 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,41 @@ 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; + } + + // @todo Add logic for not displaying hidden modules in + // https://drupal.org/node/3117829. + $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/Controller/ThemeController.php b/core/modules/system/src/Controller/ThemeController.php index 0a8ad31cf81..1f3e61865cb 100644 --- a/core/modules/system/src/Controller/ThemeController.php +++ b/core/modules/system/src/Controller/ThemeController.php @@ -6,6 +6,7 @@ use Drupal\Core\Config\PreExistingConfigException; use Drupal\Core\Config\UnmetDependenciesException; use Drupal\Core\Controller\ControllerBase; +use Drupal\Core\Extension\MissingDependencyException; use Drupal\Core\Extension\ThemeExtensionList; use Drupal\Core\Extension\ThemeHandlerInterface; use Drupal\Core\Extension\ThemeInstallerInterface; @@ -161,6 +162,9 @@ public function install(Request $request) { catch (UnmetDependenciesException $e) { $this->messenger()->addError($e->getTranslatedMessage($this->getStringTranslation(), $theme)); } + catch (MissingDependencyException $e) { + $this->messenger()->addError($this->t('Unable to install @theme due to missing module dependencies.', ['@theme' => $theme])); + } return $this->redirect('system.themes_page'); } diff --git a/core/modules/system/src/Form/ModulesListForm.php b/core/modules/system/src/Form/ModulesListForm.php index f32dad0909c..d0c0a6fbb2a 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,16 @@ 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]); + // @todo Add logic for not displaying hidden modules in + // https://drupal.org/node/3117829. + if ($incompatible = $this->checkDependencyMessage($modules, $dependency, $dependency_object)) { + $row['#requires'][$dependency] = $incompatible; $row['enable']['#disabled'] = TRUE; + 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]); - } - } + + $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 00000000000..febfbfa43fd --- /dev/null +++ b/core/modules/system/src/ModuleDependencyMessageTrait.php @@ -0,0 +1,55 @@ +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 661677b51a8..5ae24c07fbf 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->status ? t('@theme', ['@theme (theme)' => $theme->info['name']]) : t('@theme (theme) (disabled)', ['@theme' => $theme->info['name']]); + } + } + } + $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/system.install b/core/modules/system/system.install index 8f0f19e517a..97b8dd04d1d 100644 --- a/core/modules/system/system.install +++ b/core/modules/system/system.install @@ -955,12 +955,6 @@ function system_requirements($phase) { $php_incompatible_extensions[$file->info['type']][] = $name; } - // @todo Remove this 'if' block to allow checking requirements of themes - // https://www.drupal.org/project/drupal/issues/474684. - if ($file->info['type'] !== 'module') { - continue; - } - // Check the module's required modules. /** @var \Drupal\Core\Extension\Dependency $requirement */ foreach ($file->requires as $requirement) { diff --git a/core/modules/system/templates/system-themes-page.html.twig b/core/modules/system/templates/system-themes-page.html.twig index 6e65d7641b7..aad55870762 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,11 @@ {%- endif -%}
{{ theme.description }}
+ {% if theme.module_dependencies %} +
+ {{ 'Requires: @module_dependencies'|t({ '@module_dependencies': theme.module_dependencies|render }) }} +
+ {% 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 a210cd37a94..ffa055b27bf 100644 --- a/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php +++ b/core/modules/system/tests/src/Functional/Form/ModulesListFormWebTest.php @@ -27,19 +27,14 @@ class ModulesListFormWebTest extends BrowserTestBase { protected function setUp() { parent::setUp(); \Drupal::state()->set('system_test.module_hidden', FALSE); + $this->drupalLogin($this->drupalCreateUser(['administer modules', 'administer permissions'])); } /** * Tests the module list form. */ public function testModuleListForm() { - $this->drupalLogin( - $this->drupalCreateUser( - ['administer modules', 'administer permissions'] - ) - ); $this->drupalGet('admin/modules'); - $this->assertResponse('200'); // 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 +60,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); // Confirm that the error message is shown. $this->assertSession() @@ -81,4 +70,18 @@ public function testModulesListFormWithInvalidInfoFile() { $this->assertSession()->elementExists('xpath', '//input[@name="text"]'); } + /** + * Confirm that module 'Required By' descriptions include dependent themes. + */ + public function testRequiredByThemeMessage() { + $this->drupalGet('admin/modules'); + $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)'); + } + } 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 00000000000..e2a98252768 --- /dev/null +++ b/core/modules/system/tests/src/Functional/Theme/ThemeUiTest.php @@ -0,0 +1,321 @@ + 'Help', + 'test_module_required_by_theme' => 'Test Module Required by Theme', + 'test_another_module_required_by_theme' => 'Test Another Module Required by Theme', + ]; + + /** + * {@inheritdoc} + */ + protected function setUp() { + parent::setUp(); + + $this->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. + * + * @param string $theme_name + * The name of the theme being tested. + * @param string[] $first_modules + * Machine names of first modules to enable. + * @param string[] $second_modules + * Machine names of second modules to enable. + * @param string[] $required_by_messages + * Expected messages when attempting to uninstall $module_names. + * @param string $base_theme_to_uninstall + * The name of the theme $theme_name has set as a base theme. + * @param string[] $base_theme_module_names + * Machine names of the modules required by $base_theme_to_uninstall. + * + * @dataProvider providerTestThemeInstallWithModuleDependencies + */ + public function testThemeInstallWithModuleDependencies($theme_name, array $first_modules, array $second_modules, array $required_by_messages, $base_theme_to_uninstall, array $base_theme_module_names) { + $assert_session = $this->assertSession(); + $page = $this->getSession()->getPage(); + $all_dependent_modules = array_merge($first_modules, $second_modules); + $this->drupalGet('admin/appearance'); + $assert_module_enabled_message = function ($enabled_modules) { + $count = count($enabled_modules); + $module_enabled_text = $count === 1 ? "{$this->testModules[$enabled_modules[0]]} has been enabled." : $count . " modules have been enabled:"; + $this->assertSession()->pageTextContains($module_enabled_text); + }; + // All the modules should be listed as disabled. + foreach ($all_dependent_modules as $module) { + $expected_required_list_items[$module] = $this->testModules[$module] . " (disabled)"; + } + $this->assertUninstallableTheme($expected_required_list_items, $theme_name); + + // Enable the first group of dependee modules. + $first_module_form_post = []; + foreach ($first_modules as $module) { + $first_module_form_post["modules[$module][enable]"] = 1; + } + $this->drupalPostForm('admin/modules', $first_module_form_post, 'Install'); + $assert_module_enabled_message($first_modules); + + $this->drupalGet('admin/appearance'); + + // Confirm the theme is still uninstallable due to a remaining module + // dependency. + // The modules that have already been enabled will no longer be listed as + // disabled. + foreach ($first_modules as $module) { + $expected_required_list_items[$module] = $this->testModules[$module]; + } + $this->assertUninstallableTheme($expected_required_list_items, $theme_name); + + // Enable the second group of dependee modules. + $second_module_form_post = []; + foreach ($second_modules as $module) { + $second_module_form_post["modules[$module][enable]"] = 1; + } + $this->drupalPostForm('admin/modules', $second_module_form_post, 'Install'); + $assert_module_enabled_message($second_modules); + + // 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 ($all_dependent_modules 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($all_dependent_modules, $base_theme_module_names); + $this->uninstallModules($modules_to_uninstall); + + if (!empty($base_theme_to_uninstall)) { + $this->uninstallTheme($base_theme_to_uninstall); + $this->drupalGet('admin/modules/uninstall'); + $this->uninstallModules($base_theme_module_names); + } + } + + /** + * Uninstalls modules via the admin UI. + * + * @param string[] $module_names + * An array of module machine names. + */ + protected function uninstallModules(array $module_names) { + $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'); + $assert_session->pageTextContains('The following modules will be completely uninstalled from your site, and all data from these modules will be lost!'); + $assert_session->pageTextContains('Would you like to continue with uninstalling the above?'); + foreach ($module_names as $module_name) { + $assert_session->pageTextContains($this->testModules[$module_name]); + } + $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(). + * + * @return array + * An array of arrays. Details on the specific elements can be found in the + * function body. + */ + public function providerTestThemeInstallWithModuleDependencies() { + // Data provider values with the following keys: + // -'theme_name': The name of the theme being tested. + // -'first_modules': Array of module machine names to enable first. + // -'second_modules': Array of module machine names to enable second. + // -'required_by_messages': Array for checking the messages explaining why a + // module can't be uninstalled. The array key is the selector where the + // message should appear, the array value is the expected message. + // -'base_theme_to_uninstall': The name of a base theme that needs to be + // uninstalled before modules it depends on can be uninstalled. + // -'base_theme_module_names': Array of machine names of the modules + // required by base_theme_to_uninstall. + 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_modules' => [ + 'test_module_required_by_theme', + 'test_another_module_required_by_theme', + ], + 'second_modules' => [ + 'help', + ], + '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', + ], + 'base_theme_to_uninstall' => 'Test Theme Depending on Modules', + 'base_theme_module_names' => [ + 'test_module_required_by_theme', + 'test_another_module_required_by_theme', + ], + ], + 'Test Theme Depending on Modules' => [ + 'theme_name' => 'Test Theme Depending on Modules', + 'first_modules' => [ + 'test_module_required_by_theme', + ], + 'second_modules' => [ + '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', + ], + 'base_theme_to_uninstall' => '', + 'base_theme_module_names' => [], + ], + 'test theme with a base theme depending on modules' => [ + 'theme_name' => 'Test Theme with a Base Theme Depending on Modules', + 'first_modules' => [ + 'test_module_required_by_theme', + ], + 'second_modules' => [ + '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', + ], + 'base_theme_to_uninstall' => 'Test Theme Depending on Modules', + 'base_theme_module_names' => [ + 'test_module_required_by_theme', + 'test_another_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->assertTrue(in_array($item->getText(), $expected_requires_list_items)); + } + + $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()); + } + + /** + * Tests installing a theme with incompatible module dependencies. + */ + public function testInstallModuleWithIncompatibleDependencies() { + $this->container->get('module_installer')->install(['test_module_compatible_constraint', 'test_module_incompatible_constraint']); + $this->drupalGet('admin/appearance'); + $theme_container = $this->getSession()->getPage()->find('css', 'h3:contains("Test Theme Depending on Version Constrained Modules")')->getParent(); + $this->assertContains('Requires: Test Module Theme Depends on with Compatible ConstraintTest Module Theme Depends on with Incompatible Constraint (>=8.x-2.x) (incompatible with version 8.x-1.8)', $theme_container->getText()); + $this->assertContains('This theme requires the listed modules to operate correctly.', $theme_container->getText()); + } + +} diff --git a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php index 9c60fce0eb2..34ecfa58ea5 100644 --- a/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php +++ b/core/modules/system/tests/src/Functional/UpdateSystem/UpdateScriptTest.php @@ -24,7 +24,13 @@ class UpdateScriptTest extends BrowserTestBase { * * @var array */ - public static $modules = ['update_script_test', 'dblog', 'language']; + protected static $modules = [ + 'update_script_test', + 'dblog', + 'language', + 'test_module_required_by_theme', + 'test_another_module_required_by_theme', + ]; /** * {@inheritdoc} @@ -61,7 +67,11 @@ protected function setUp() { parent::setUp(); $this->updateUrl = Url::fromRoute('system.db_update'); $this->statusReportUrl = Url::fromRoute('system.status'); - $this->updateUser = $this->drupalCreateUser(['administer software updates', 'access site in maintenance mode']); + $this->updateUser = $this->drupalCreateUser([ + 'administer software updates', + 'access site in maintenance mode', + 'administer themes', + ]); } /** @@ -175,6 +185,31 @@ public function testRequirements() { $this->drupalGet($this->updateUrl, ['external' => TRUE]); $this->assertSession()->assertEscaped('Node (Version <7.x-0.0-dev required)'); $this->assertSession()->responseContains('Update script test requires this module and version. Currently using Node version ' . \Drupal::VERSION); + + // Test that issues with modules that themes depend on are properly + // displayed. + $this->assertSession()->responseNotContains('Test Module Required by Theme'); + $this->drupalGet('admin/appearance'); + $this->getSession()->getPage()->clickLink('Install Test Theme Depending on Modules theme'); + $this->assertSession()->addressEquals('admin/appearance'); + $this->assertSession()->pageTextContains('The Test Theme Depending on Modules theme has been installed'); + + // Ensure that when a theme depends on a module and that module's + // requirements change, errors are displayed in the same manner as modules + // depending on other modules. + \Drupal::state()->set('test_theme_depending_on_modules.system_info_alter', ['dependencies' => ['test_module_required_by_theme (<7.x-0.0-dev)']]); + $this->drupalGet($this->updateUrl, ['external' => TRUE]); + $this->assertSession()->assertEscaped('Test Module Required by Theme (Version <7.x-0.0-dev required)'); + $this->assertSession()->responseContains('Test Theme Depending on Modules requires this module and version. Currently using Test Module Required by Theme version ' . \Drupal::VERSION); + + // Ensure that when a theme is updated to depend on an unavailable module, + // errors are displayed in the same manner as modules depending on other + // modules. + \Drupal::state()->set('test_theme_depending_on_modules.system_info_alter', ['dependencies' => ['a_module_theme_needs_that_does_not_exist']]); + $this->drupalGet($this->updateUrl, ['external' => TRUE]); + $this->assertSession()->responseContains('a_module_theme_needs_that_does_not_exist (Missing)'); + $this->assertSession()->responseContains('Test Theme Depending on Modules requires this module.'); + } /** diff --git a/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml new file mode 100644 index 00000000000..ed5cba029db --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_compatible_constraint/test_module_compatible_constraint.info.yml @@ -0,0 +1,4 @@ +name: Test Module Theme Depends on with Compatible Constraint +type: module +package: Testing +version: '8.x-1.2' diff --git a/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml new file mode 100644 index 00000000000..11b0b62523f --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_module_incompatible_constraint/test_module_incompatible_constraint.info.yml @@ -0,0 +1,4 @@ +name: Test Module Theme Depends on with Incompatible Constraint +type: module +package: Testing +version: '8.x-1.8' diff --git a/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml new file mode 100644 index 00000000000..a0f34359bff --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_depending_on_constrained_modules/test_theme_depending_on_constrained_modules.info.yml @@ -0,0 +1,6 @@ +name: Test Theme Depending on Version Constrained Modules +type: theme +base theme: stark +dependencies: + - test_module_compatible_constraint (>=8.x-1.x) + - test_module_incompatible_constraint (>=8.x-2.x) 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 00000000000..29e2202c288 --- /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,4 @@ +name: Test Another Module Required by Theme +type: module +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 00000000000..81a1c2e880e --- /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,4 @@ +name: Test Module Required by Theme +type: module +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.module b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.module new file mode 100644 index 00000000000..191a0cf297f --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_module_required_by_theme/test_module_required_by_theme.module @@ -0,0 +1,20 @@ +getName() == 'test_theme_depending_on_modules') { + $new_info = \Drupal::state()->get('test_theme_depending_on_modules.system_info_alter'); + if ($new_info) { + $info = $new_info + $info; + } + } +} 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 00000000000..48a6af4a492 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_depending_on_modules/test_theme_depending_on_modules.info.yml @@ -0,0 +1,6 @@ +name: Test Theme Depending on Modules +type: theme +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_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml b/core/modules/system/tests/themes/test_theme_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml new file mode 100644 index 00000000000..da54e8aaca3 --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_depending_on_nonexisting_module/test_theme_depending_on_nonexisting_module.info.yml @@ -0,0 +1,6 @@ +name: Test Theme Depending on Nonexisting Module +type: theme +base theme: stark +version: VERSION +dependencies: + - test_module_non_existing 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 00000000000..5146e0d483d --- /dev/null +++ b/core/modules/system/tests/themes/test_theme_mixed_module_dependencies/test_theme_mixed_module_dependencies.info.yml @@ -0,0 +1,5 @@ +name: Test Theme with a Module Dependency and Base Theme with a Different Module Dependency +type: theme +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 00000000000..cde5b21ccaf --- /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,3 @@ +name: Test Theme with a Base Theme Depending on Modules +type: theme +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 c8dd0337a80..13c7aa28265 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,79 @@ public function testInstallNameTooLong() { } } + /** + * Tests installing a theme with unmet module dependencies. + * + * @dataProvider providerTestInstallThemeWithUnmetModuleDependencies + */ + public function testInstallThemeWithUnmetModuleDependencies($theme_name, $installed_modules, $message) { + $this->container->get('module_installer')->install($installed_modules); + $themes = $this->themeHandler()->listInfo(); + $this->assertEmpty($themes); + $this->expectException(MissingDependencyException::class); + $this->expectExceptionMessage($message); + $this->themeInstaller()->install([$theme_name]); + } + + /** + * Data provider for testInstallThemeWithUnmetModuleDependencies(). + */ + public function providerTestInstallThemeWithUnmetModuleDependencies() { + return [ + 'theme with uninstalled module dependencies' => [ + 'test_theme_depending_on_modules', + [], + "Unable to install theme: 'test_theme_depending_on_modules' due to unmet module dependencies: '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', + [], + "Unable to install theme: 'test_theme_with_a_base_theme_depending_on_modules' due to unmet module dependencies: 'test_module_required_by_theme, test_another_module_required_by_theme'.", + ], + 'theme and base theme have uninstalled module dependencies' => [ + 'test_theme_mixed_module_dependencies', + [], + "Unable to install theme: 'test_theme_mixed_module_dependencies' due to unmet 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', + ['help'], + "Unable to install theme: 'test_theme_mixed_module_dependencies' due to unmet module dependencies: 'test_module_required_by_theme, test_another_module_required_by_theme'.", + ], + 'theme with module dependencies not installed, base theme module dependencies are already installed, ' => [ + 'test_theme_mixed_module_dependencies', + ['test_module_required_by_theme', 'test_another_module_required_by_theme'], + "Unable to install theme: 'test_theme_mixed_module_dependencies' due to unmet module dependencies: 'help'.", + ], + 'theme depending on a module that does not exist' => [ + 'test_theme_depending_on_nonexisting_module', + [], + "Unable to install theme: 'test_theme_depending_on_nonexisting_module' due to unmet module dependencies: 'test_module_non_existing", + ], + 'theme depending on an installed but incompatible module' => [ + 'test_theme_depending_on_constrained_modules', + ['test_module_compatible_constraint', 'test_module_incompatible_constraint'], + "Unable to install theme: Test Module Theme Depends on with Incompatible Constraint (>=8.x-2.x) (incompatible with version 8.x-1.8)", + ], + ]; + } + + /** + * Tests installing a theme with module dependencies that are met. + */ + public function testInstallThemeWithMetModuleDependencies() { + $name = 'test_theme_depending_on_modules'; + $themes = $this->themeHandler()->listInfo(); + $this->assertArrayNotHasKey($name, $themes); + $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->assertArrayHasKey($name, $themes); + $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 00000000000..53ee6660101 --- /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 1108c7f2f6f..36d958dd365 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,11 @@